From 8266291d14d447a4442ff218b74baba21f8d6d05 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Wed, 28 Oct 2026 18:12:31 +0900 Subject: [PATCH 1/6] test(proxy): add skeleton for URL-route model allowlist enforcement Mark the generateContent, streamGenerateContent and image deployment routes where a virtual key is served a model outside its allowlist because the access check reads the body model while the handler routes on the URL model. Cases are skipped until the route-agnostic fix lands. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- .../test_url_route_model_access.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/integration/authorization/test_url_route_model_access.py diff --git a/tests/integration/authorization/test_url_route_model_access.py b/tests/integration/authorization/test_url_route_model_access.py new file mode 100644 index 00000000000..d6917fa61d5 --- /dev/null +++ b/tests/integration/authorization/test_url_route_model_access.py @@ -0,0 +1,83 @@ +"""Virtual-key model allowlist enforcement on routes that bind the model from the URL. + +Tracking issue: https://github.com/BerriAI/litellm/issues/48760 + +A virtual key scoped with ``models: ["allowed-model"]`` is correctly denied a model +named in the request body, but the Google ``generateContent`` and +``streamGenerateContent`` routes and the ``/openai/deployments/{model}/images/...`` +routes bind the model from the URL and route on it, while the access check reads the +body model. A body model the key may use plus a denied model in the URL is served the +denied model. The open PR #41852 closes the chat, completions and embeddings routes, +not these. + +These skeleton cases name the routes the fix must cover. They are skipped until the +fix lands; the implementation re-checks the resolved ``self.data["model"]`` at the +single request-processing choke point so every route is covered at once. See the +tracking issue for the full reproduction, the N-of-N matrix and the fix-control run. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +import pytest + +pytestmark = pytest.mark.skip( + reason="WIP, enforcing the model allowlist on URL-bound model routes, see issue #48760" +) + + +@dataclass(frozen=True, slots=True) +class _UrlRouteCase: + name: str + method: str + path: str + body_model: str + served_denied: bool + + +# A key scoped to "allowed-model". Each case sends "denied-model" in the URL and +# "allowed-model" in the body. served_denied is the behaviour on current main that +# the fix must turn into a 403. +_CASES: Final = ( + _UrlRouteCase( + "generate_content", + "POST", + "/v1beta/models/denied-model:generateContent", + "allowed-model", + True, + ), + _UrlRouteCase( + "stream_generate_content", + "POST", + "/v1beta/models/denied-model:streamGenerateContent", + "allowed-model", + True, + ), + _UrlRouteCase( + "images_generations_deployment", + "POST", + "/openai/deployments/denied-model/images/generations", + "allowed-model", + True, + ), + _UrlRouteCase( + "images_edits_deployment", + "POST", + "/openai/deployments/denied-model/images/edits", + "allowed-model", + True, + ), +) + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name) +def test_url_route_model_outside_allowlist_is_denied(case: _UrlRouteCase) -> None: + """A URL-bound model outside the key allowlist must be denied before dispatch. + + The served model, not just the body model, must be inside the key's allowed set. + The request below sends an allowed body model and a denied URL model, and must + return 403 key_model_access_denied with no upstream call. + """ + raise NotImplementedError("fix and assertions pending, see issue #48760") From 4d7a35a29d1c0487fafd6141c78efafdf768d93c Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Wed, 28 Oct 2026 18:44:22 +0900 Subject: [PATCH 2/6] fix(proxy): enforce the key and team model allowlist on the routed model Auth reads the body model while the handlers route on the URL model, so a key scoped to one model is served a denied model on routes that bind the model from the URL, generateContent, streamGenerateContent and the image deployment paths. Re-check the resolved model against the key and team allowlist in route_request, the single point every dispatch passes through, and pass the key from the image generation handler. This closes every URL bound route at once, including the chat route and routes added later. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- litellm/proxy/image_endpoints/endpoints.py | 1 + litellm/proxy/route_llm_request.py | 15 ++++ .../test_url_route_model_access.py | 83 ------------------- tests/unit/proxy/test_route_llm_request.py | 63 ++++++++++++++ 4 files changed, 79 insertions(+), 83 deletions(-) delete mode 100644 tests/integration/authorization/test_url_route_model_access.py diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 28fb9baf2aa..e93b22cb716 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -156,6 +156,7 @@ async def image_generation( route_type="aimage_generation", llm_router=llm_router, user_model=user_model, + user_api_key_dict=user_api_key_dict, ) response = await llm_call diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index cb226929167..ddb6f6bb184 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -720,6 +720,21 @@ async def route_request( """ Common helper to route the request """ + # Enforce the key and team allowlist on the model that will actually be routed, + # not only the body model auth read. Routes that bind the model from the URL + # resolve it into data["model"] before dispatch, so this is the single place + # that covers every route, including ones added later. + dispatched_model: Final = data.get("model") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # route_request takes an untyped dict body + if user_api_key_dict is not None and llm_router is not None and isinstance(dispatched_model, str): + from litellm.proxy.auth.auth_checks import can_key_call_resolved_model + + await can_key_call_resolved_model( + model=dispatched_model, + llm_model_list=llm_router.model_list, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Router.model_list is an untyped list + valid_token=user_api_key_dict, + llm_router=llm_router, + ) + router_only_settings_from_override: Final = _merge_router_settings_override(data) # pyright: ignore[reportUnknownArgumentType] # route_request takes an untyped dict body try: return await _route_request_single_attempt( diff --git a/tests/integration/authorization/test_url_route_model_access.py b/tests/integration/authorization/test_url_route_model_access.py deleted file mode 100644 index d6917fa61d5..00000000000 --- a/tests/integration/authorization/test_url_route_model_access.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Virtual-key model allowlist enforcement on routes that bind the model from the URL. - -Tracking issue: https://github.com/BerriAI/litellm/issues/48760 - -A virtual key scoped with ``models: ["allowed-model"]`` is correctly denied a model -named in the request body, but the Google ``generateContent`` and -``streamGenerateContent`` routes and the ``/openai/deployments/{model}/images/...`` -routes bind the model from the URL and route on it, while the access check reads the -body model. A body model the key may use plus a denied model in the URL is served the -denied model. The open PR #41852 closes the chat, completions and embeddings routes, -not these. - -These skeleton cases name the routes the fix must cover. They are skipped until the -fix lands; the implementation re-checks the resolved ``self.data["model"]`` at the -single request-processing choke point so every route is covered at once. See the -tracking issue for the full reproduction, the N-of-N matrix and the fix-control run. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - -import pytest - -pytestmark = pytest.mark.skip( - reason="WIP, enforcing the model allowlist on URL-bound model routes, see issue #48760" -) - - -@dataclass(frozen=True, slots=True) -class _UrlRouteCase: - name: str - method: str - path: str - body_model: str - served_denied: bool - - -# A key scoped to "allowed-model". Each case sends "denied-model" in the URL and -# "allowed-model" in the body. served_denied is the behaviour on current main that -# the fix must turn into a 403. -_CASES: Final = ( - _UrlRouteCase( - "generate_content", - "POST", - "/v1beta/models/denied-model:generateContent", - "allowed-model", - True, - ), - _UrlRouteCase( - "stream_generate_content", - "POST", - "/v1beta/models/denied-model:streamGenerateContent", - "allowed-model", - True, - ), - _UrlRouteCase( - "images_generations_deployment", - "POST", - "/openai/deployments/denied-model/images/generations", - "allowed-model", - True, - ), - _UrlRouteCase( - "images_edits_deployment", - "POST", - "/openai/deployments/denied-model/images/edits", - "allowed-model", - True, - ), -) - - -@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name) -def test_url_route_model_outside_allowlist_is_denied(case: _UrlRouteCase) -> None: - """A URL-bound model outside the key allowlist must be denied before dispatch. - - The served model, not just the body model, must be inside the key's allowed set. - The request below sends an allowed body model and a denied URL model, and must - return 403 key_model_access_denied with no upstream call. - """ - raise NotImplementedError("fix and assertions pending, see issue #48760") diff --git a/tests/unit/proxy/test_route_llm_request.py b/tests/unit/proxy/test_route_llm_request.py index 6044475652c..f560e6543b6 100644 --- a/tests/unit/proxy/test_route_llm_request.py +++ b/tests/unit/proxy/test_route_llm_request.py @@ -3025,3 +3025,66 @@ async def test_user_config_routers_discard_the_least_recently_used_router_past_c assert first_handler() is not None assert second_handler() is None routers.clear() + + +def _allowlist_router() -> "litellm.Router": + return litellm.Router( + model_list=[ + { + "model_name": "allowed-model", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake", "mock_response": "ok"}, + }, + { + "model_name": "denied-model", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "fake", "mock_response": "ok"}, + }, + ] + ) + + +@pytest.mark.asyncio +async def test_route_request_denies_resolved_model_outside_key_allowlist(): + from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth + + router = _allowlist_router() + key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) + data = {"model": "denied-model", "messages": [{"role": "user", "content": "hi"}]} + + with pytest.raises(ProxyException) as exc: + await route_request(data, router, None, "acompletion", user_api_key_dict=key) + assert exc.value.type == ProxyErrorTypes.key_model_access_denied.value + + +@pytest.mark.asyncio +async def test_route_request_allows_resolved_model_inside_key_allowlist(): + from litellm.proxy._types import UserAPIKeyAuth + + router = _allowlist_router() + key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) + data = {"model": "allowed-model", "messages": [{"role": "user", "content": "hi"}]} + + response = await route_request(data, router, None, "acompletion", user_api_key_dict=key) + assert await response if asyncio.iscoroutine(response) else response + + +@pytest.mark.asyncio +async def test_route_request_without_a_key_skips_the_allowlist_check(): + router = _allowlist_router() + data = {"model": "denied-model", "messages": [{"role": "user", "content": "hi"}]} + + response = await route_request(data, router, None, "acompletion") + assert await response if asyncio.iscoroutine(response) else response + + +@pytest.mark.asyncio +async def test_route_request_list_model_is_not_blocked_by_the_allowlist_check(): + from litellm.proxy._types import UserAPIKeyAuth + + router = _allowlist_router() + key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) + data = {"model": ["allowed-model", "denied-model"], "messages": [{"role": "user", "content": "hi"}]} + + # A list model value is handled by the per-model auth path, not this backstop, + # so the check must not raise on it. + with contextlib.suppress(Exception): + await route_request(data, router, None, "acompletion", user_api_key_dict=key) From 75f5cdf13423cc9e44ef0ea43d26afbbfe5eeee9 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Wed, 28 Oct 2026 19:19:40 +0900 Subject: [PATCH 3/6] fix(proxy): authorize each model a batch request routes to Split the dispatched model the way auth splits the request model before the allowlist check: a comma-separated value is a batch the router fans out and a list is a set of names, so each non-empty part is authorized on its own. The earlier check treated a comma value as one model, so a key allowed both models of a batch was wrongly rejected. Replace the list-model test that asserted nothing with parametrized tests that assert the denial type for a single, comma and list model outside the allowlist, and assert the served content for an allowed model and for a comma batch that serves both models. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- litellm/proxy/route_llm_request.py | 33 ++++++--- tests/unit/proxy/test_route_llm_request.py | 84 ++++++++++++---------- 2 files changed, 72 insertions(+), 45 deletions(-) diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index ddb6f6bb184..608985910c5 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -2,7 +2,7 @@ import asyncio import hashlib import json from collections import OrderedDict -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from datetime import datetime from types import MappingProxyType @@ -710,6 +710,22 @@ def _merge_router_settings_override( return _ROUTER_ONLY_SETTINGS.intersection(router_settings_from_override) +def _models_to_authorize(model: object) -> Iterator[str]: + """Each model the dispatch can reach, split the way auth splits the request model. + + A comma-separated value is a batch the router fans out, and a list is a set of + names, so every non-empty part is authorized on its own. + """ + items: Final[tuple[object, ...]] = tuple(model) if isinstance(model, list) else (model,) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] # a list body model holds unknown element types + for item in items: + if not isinstance(item, str): + continue + for part in item.split(","): + name = part.strip() + if name: + yield name + + async def route_request( data: dict, llm_router: LitellmRouter | None, @@ -725,15 +741,16 @@ async def route_request( # resolve it into data["model"] before dispatch, so this is the single place # that covers every route, including ones added later. dispatched_model: Final = data.get("model") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # route_request takes an untyped dict body - if user_api_key_dict is not None and llm_router is not None and isinstance(dispatched_model, str): + if user_api_key_dict is not None and llm_router is not None: from litellm.proxy.auth.auth_checks import can_key_call_resolved_model - await can_key_call_resolved_model( - model=dispatched_model, - llm_model_list=llm_router.model_list, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Router.model_list is an untyped list - valid_token=user_api_key_dict, - llm_router=llm_router, - ) + for model_name in _models_to_authorize(dispatched_model): # pyright: ignore[reportUnknownArgumentType] # dispatched_model comes from the untyped dict body + await can_key_call_resolved_model( + model=model_name, + llm_model_list=llm_router.model_list, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Router.model_list is an untyped list + valid_token=user_api_key_dict, + llm_router=llm_router, + ) router_only_settings_from_override: Final = _merge_router_settings_override(data) # pyright: ignore[reportUnknownArgumentType] # route_request takes an untyped dict body try: diff --git a/tests/unit/proxy/test_route_llm_request.py b/tests/unit/proxy/test_route_llm_request.py index f560e6543b6..ad411995600 100644 --- a/tests/unit/proxy/test_route_llm_request.py +++ b/tests/unit/proxy/test_route_llm_request.py @@ -3027,64 +3027,74 @@ async def test_user_config_routers_discard_the_least_recently_used_router_past_c routers.clear() -def _allowlist_router() -> "litellm.Router": +def _allowlist_router() -> litellm.Router: return litellm.Router( model_list=[ { - "model_name": "allowed-model", - "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake", "mock_response": "ok"}, - }, - { - "model_name": "denied-model", - "litellm_params": {"model": "openai/gpt-4o", "api_key": "fake", "mock_response": "ok"}, - }, + "model_name": name, + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake", "mock_response": f"from {name}"}, + } + for name in ("model-a", "model-b", "model-c") ] ) -@pytest.mark.asyncio -async def test_route_request_denies_resolved_model_outside_key_allowlist(): - from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth +def _hi() -> list[dict[str, str]]: + return [{"role": "user", "content": "hi"}] - router = _allowlist_router() - key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) - data = {"model": "denied-model", "messages": [{"role": "user", "content": "hi"}]} + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested", + ["model-c", "model-a,model-c", ["model-a", "model-c"]], + ids=["single", "comma_batch", "list"], +) +async def test_route_request_denies_any_routed_model_outside_key_allowlist(requested): + from litellm.proxy._types import ProxyErrorTypes, ProxyException + + key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) with pytest.raises(ProxyException) as exc: - await route_request(data, router, None, "acompletion", user_api_key_dict=key) + await route_request( + {"model": requested, "messages": _hi()}, _allowlist_router(), None, "acompletion", user_api_key_dict=key + ) assert exc.value.type == ProxyErrorTypes.key_model_access_denied.value @pytest.mark.asyncio -async def test_route_request_allows_resolved_model_inside_key_allowlist(): - from litellm.proxy._types import UserAPIKeyAuth +async def test_route_request_serves_a_model_inside_key_allowlist(): + key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) - router = _allowlist_router() - key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) - data = {"model": "allowed-model", "messages": [{"role": "user", "content": "hi"}]} + llm_call = await route_request( + {"model": "model-a", "messages": _hi()}, _allowlist_router(), None, "acompletion", user_api_key_dict=key + ) + response = await llm_call - response = await route_request(data, router, None, "acompletion", user_api_key_dict=key) - assert await response if asyncio.iscoroutine(response) else response + assert response.choices[0].message.content == "from model-a" @pytest.mark.asyncio -async def test_route_request_without_a_key_skips_the_allowlist_check(): - router = _allowlist_router() - data = {"model": "denied-model", "messages": [{"role": "user", "content": "hi"}]} +async def test_route_request_serves_a_comma_batch_when_key_allows_every_model(): + key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) - response = await route_request(data, router, None, "acompletion") - assert await response if asyncio.iscoroutine(response) else response + llm_call = await route_request( + {"model": "model-a, model-b", "messages": _hi()}, + _allowlist_router(), + None, + "acompletion", + user_api_key_dict=key, + ) + responses = await llm_call + + assert sorted(response.choices[0].message.content for response in responses) == [ + "from model-a", + "from model-b", + ] @pytest.mark.asyncio -async def test_route_request_list_model_is_not_blocked_by_the_allowlist_check(): - from litellm.proxy._types import UserAPIKeyAuth +async def test_route_request_without_a_key_leaves_model_access_to_auth(): + llm_call = await route_request({"model": "model-c", "messages": _hi()}, _allowlist_router(), None, "acompletion") + response = await llm_call - router = _allowlist_router() - key = UserAPIKeyAuth(api_key="sk-test", models=["allowed-model"]) - data = {"model": ["allowed-model", "denied-model"], "messages": [{"role": "user", "content": "hi"}]} - - # A list model value is handled by the per-model auth path, not this backstop, - # so the check must not raise on it. - with contextlib.suppress(Exception): - await route_request(data, router, None, "acompletion", user_api_key_dict=key) + assert response.choices[0].message.content == "from model-c" From ffcc8c792f7ba075019aa78aa8e19e0f8edd969e Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Wed, 28 Oct 2026 19:42:36 +0900 Subject: [PATCH 4/6] fix(proxy): authorize the resolved model before alias rewrites, not after Check the model the handler resolved at the point it is resolved, in common_processing_pre_call_logic and the image generation handler, before the global, key and team alias rewrites. The earlier placement in route_request saw the post-rewrite name, so a key allowed a global alias was denied once the alias was rewritten to its target. Checking the requested name keeps every alias grant that auth already honored when it read the body model. The new can_key_call_requested_models splits a comma batch and a list the way auth splits the request model and authorizes each name, so a key allowed every model of a batch still passes. Add allowlist tests covering single, comma and list models in and out of the allowlist, and an alias-only grant. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- litellm/proxy/auth/auth_checks.py | 36 +++++++++++ litellm/proxy/common_request_processing.py | 10 ++- litellm/proxy/image_endpoints/endpoints.py | 11 +++- litellm/proxy/route_llm_request.py | 34 +--------- tests/unit/proxy/auth/test_auth_checks.py | 53 ++++++++++++++++ tests/unit/proxy/test_route_llm_request.py | 73 ---------------------- 6 files changed, 108 insertions(+), 109 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 448643ef59b..e7f3bc1c37e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -5561,6 +5561,42 @@ async def _personal_key_owner( return None +def _requested_model_names(model: object) -> Iterator[str]: + """Each model name a request can route to, split the way auth reads the request model. + + A comma-separated value is a batch the router fans out and a list is a set of + names, so every non-empty part is a name of its own. + """ + items: Final[tuple[object, ...]] = tuple(model) if isinstance(model, list) else (model,) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] # a list body model holds unknown element types + for item in items: + if not isinstance(item, str): + continue + for part in item.split(","): + name = part.strip() + if name: + yield name + + +async def can_key_call_requested_models( + model: object, + valid_token: UserAPIKeyAuth, + llm_router: litellm.Router, +) -> None: + """Authorize every model a request resolves to, before any alias rewrite. + + Call this where an endpoint model from the URL path or query is resolved into the + request. The names checked are the ones the caller asked for, so global, key and + team alias grants apply exactly as they do in auth, which reads the body model. + """ + for name in _requested_model_names(model): + await can_key_call_resolved_model( + model=name, + llm_model_list=llm_router.model_list, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Router.model_list is an untyped list + valid_token=valid_token, + llm_router=llm_router, + ) + + async def can_key_call_resolved_model( model: str, llm_model_list: Sequence[object] | None, diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 24a36a7f2dc..9e58c662726 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -90,6 +90,7 @@ from litellm.litellm_core_utils.streaming_handler import ( from litellm.litellm_core_utils.tool_fields import DropPolicy, MalformedField, drop_policy, resolve_tool_fields from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.auth.auth_checks import ( + can_key_call_requested_models, can_key_call_resolved_model, is_model_cost_zero, request_skips_budget_checks, @@ -2364,13 +2365,20 @@ class ProxyBaseLLMRequestProcessing: if isinstance(model, str): reject_url_valued_destination("model", model) - self.data["model"] = resolve_inference_model( + resolved_model: Final = resolve_inference_model( self.data.get("model"), general_settings, user_model, model, kind="image_edit" if route_type == "aimage_edit" else "completion", ) + self.data["model"] = resolved_model + # Auth checked the body model; the endpoint model from the URL wins here, so + # authorize the resolved name before any alias rewrite. + if llm_router is not None: + await can_key_call_requested_models( + model=resolved_model, valid_token=user_api_key_dict, llm_router=llm_router + ) # override with user settings, these are params passed via cli if user_temperature: diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index e93b22cb716..3112e41c277 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -14,6 +14,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_str_from_messages, ) from litellm.proxy._types import * +from litellm.proxy.auth.auth_checks import can_key_call_requested_models from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, @@ -122,9 +123,16 @@ async def image_generation( if isinstance(model, str): reject_url_valued_destination("model", model) - data["model"] = resolve_inference_model( + resolved_model: Final = resolve_inference_model( data.get("model"), general_settings, user_model, model, kind="image_generation" ) + data["model"] = resolved_model + # Auth checked the body model; the deployment path model wins here, so + # authorize the resolved name before any alias rewrite. + if llm_router is not None: + await can_key_call_requested_models( + model=resolved_model, valid_token=user_api_key_dict, llm_router=llm_router + ) ### MODEL ALIAS MAPPING ### # check if model name in model alias map @@ -156,7 +164,6 @@ async def image_generation( route_type="aimage_generation", llm_router=llm_router, user_model=user_model, - user_api_key_dict=user_api_key_dict, ) response = await llm_call diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 608985910c5..cb226929167 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -2,7 +2,7 @@ import asyncio import hashlib import json from collections import OrderedDict -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from types import MappingProxyType @@ -710,22 +710,6 @@ def _merge_router_settings_override( return _ROUTER_ONLY_SETTINGS.intersection(router_settings_from_override) -def _models_to_authorize(model: object) -> Iterator[str]: - """Each model the dispatch can reach, split the way auth splits the request model. - - A comma-separated value is a batch the router fans out, and a list is a set of - names, so every non-empty part is authorized on its own. - """ - items: Final[tuple[object, ...]] = tuple(model) if isinstance(model, list) else (model,) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] # a list body model holds unknown element types - for item in items: - if not isinstance(item, str): - continue - for part in item.split(","): - name = part.strip() - if name: - yield name - - async def route_request( data: dict, llm_router: LitellmRouter | None, @@ -736,22 +720,6 @@ async def route_request( """ Common helper to route the request """ - # Enforce the key and team allowlist on the model that will actually be routed, - # not only the body model auth read. Routes that bind the model from the URL - # resolve it into data["model"] before dispatch, so this is the single place - # that covers every route, including ones added later. - dispatched_model: Final = data.get("model") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # route_request takes an untyped dict body - if user_api_key_dict is not None and llm_router is not None: - from litellm.proxy.auth.auth_checks import can_key_call_resolved_model - - for model_name in _models_to_authorize(dispatched_model): # pyright: ignore[reportUnknownArgumentType] # dispatched_model comes from the untyped dict body - await can_key_call_resolved_model( - model=model_name, - llm_model_list=llm_router.model_list, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Router.model_list is an untyped list - valid_token=user_api_key_dict, - llm_router=llm_router, - ) - router_only_settings_from_override: Final = _merge_router_settings_override(data) # pyright: ignore[reportUnknownArgumentType] # route_request takes an untyped dict body try: return await _route_request_single_attempt( diff --git a/tests/unit/proxy/auth/test_auth_checks.py b/tests/unit/proxy/auth/test_auth_checks.py index e1afa81d372..bef9c892d5e 100644 --- a/tests/unit/proxy/auth/test_auth_checks.py +++ b/tests/unit/proxy/auth/test_auth_checks.py @@ -2380,3 +2380,56 @@ async def test_bulk_eviction_clears_the_redis_copies_before_waiting_on_a_wedged_ assert refilled == [] assert redis.entries == {} + + +def _requested_models_router() -> litellm.Router: + return litellm.Router( + model_list=[ + { + "model_name": name, + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake", "mock_response": f"from {name}"}, + } + for name in ("model-a", "model-b", "model-c") + ] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested", + ["model-c", "model-a,model-c", ["model-a", "model-c"]], + ids=["single", "comma_batch", "list"], +) +async def test_can_key_call_requested_models_denies_any_model_outside_allowlist(requested): + from litellm.proxy._types import ProxyErrorTypes, ProxyException + from litellm.proxy.auth.auth_checks import can_key_call_requested_models + + key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) + with pytest.raises(ProxyException) as exc: + await can_key_call_requested_models(requested, key, _requested_models_router()) + assert exc.value.type == ProxyErrorTypes.key_model_access_denied.value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "requested", + ["model-a", "model-a,model-b", ["model-a", "model-b"]], + ids=["single", "comma_batch", "list"], +) +async def test_can_key_call_requested_models_allows_models_inside_allowlist(requested): + from litellm.proxy.auth.auth_checks import can_key_call_requested_models + + key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) + # Returns None without raising for any form that stays inside the allowlist. + assert await can_key_call_requested_models(requested, key, _requested_models_router()) is None + + +@pytest.mark.asyncio +async def test_can_key_call_requested_models_preserves_a_global_alias_grant(monkeypatch): + from litellm.proxy.auth.auth_checks import can_key_call_requested_models + + monkeypatch.setattr(litellm, "model_alias_map", {"friendly": "model-a"}) + key = UserAPIKeyAuth(api_key="sk-test", models=["friendly"]) + # A key allowed the alias may call the alias name; resolution to model-a happens + # after this check, so the grant must not be denied here. + assert await can_key_call_requested_models("friendly", key, _requested_models_router()) is None diff --git a/tests/unit/proxy/test_route_llm_request.py b/tests/unit/proxy/test_route_llm_request.py index ad411995600..6044475652c 100644 --- a/tests/unit/proxy/test_route_llm_request.py +++ b/tests/unit/proxy/test_route_llm_request.py @@ -3025,76 +3025,3 @@ async def test_user_config_routers_discard_the_least_recently_used_router_past_c assert first_handler() is not None assert second_handler() is None routers.clear() - - -def _allowlist_router() -> litellm.Router: - return litellm.Router( - model_list=[ - { - "model_name": name, - "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake", "mock_response": f"from {name}"}, - } - for name in ("model-a", "model-b", "model-c") - ] - ) - - -def _hi() -> list[dict[str, str]]: - return [{"role": "user", "content": "hi"}] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "requested", - ["model-c", "model-a,model-c", ["model-a", "model-c"]], - ids=["single", "comma_batch", "list"], -) -async def test_route_request_denies_any_routed_model_outside_key_allowlist(requested): - from litellm.proxy._types import ProxyErrorTypes, ProxyException - - key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) - - with pytest.raises(ProxyException) as exc: - await route_request( - {"model": requested, "messages": _hi()}, _allowlist_router(), None, "acompletion", user_api_key_dict=key - ) - assert exc.value.type == ProxyErrorTypes.key_model_access_denied.value - - -@pytest.mark.asyncio -async def test_route_request_serves_a_model_inside_key_allowlist(): - key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) - - llm_call = await route_request( - {"model": "model-a", "messages": _hi()}, _allowlist_router(), None, "acompletion", user_api_key_dict=key - ) - response = await llm_call - - assert response.choices[0].message.content == "from model-a" - - -@pytest.mark.asyncio -async def test_route_request_serves_a_comma_batch_when_key_allows_every_model(): - key = UserAPIKeyAuth(api_key="sk-test", models=["model-a", "model-b"]) - - llm_call = await route_request( - {"model": "model-a, model-b", "messages": _hi()}, - _allowlist_router(), - None, - "acompletion", - user_api_key_dict=key, - ) - responses = await llm_call - - assert sorted(response.choices[0].message.content for response in responses) == [ - "from model-a", - "from model-b", - ] - - -@pytest.mark.asyncio -async def test_route_request_without_a_key_leaves_model_access_to_auth(): - llm_call = await route_request({"model": "model-c", "messages": _hi()}, _allowlist_router(), None, "acompletion") - response = await llm_call - - assert response.choices[0].message.content == "from model-c" From 4aea14e3c21ab495653f376d02ef88880ad8a367 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Thu, 29 Oct 2026 02:07:43 +0900 Subject: [PATCH 5/6] fix(proxy): only re-authorize when the URL model overrides the body model Gate the allowlist re-check so it runs only when the endpoint model from the URL is present and differs from the body model auth already validated. A request whose model comes from the body, or whose endpoint model equals it, was already checked, so pass-through and search routes that resolve the body model are left alone and only a real URL override is re-authorized. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- litellm/proxy/common_request_processing.py | 8 +++++--- litellm/proxy/image_endpoints/endpoints.py | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 9e58c662726..a94116ca940 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -2373,9 +2373,11 @@ class ProxyBaseLLMRequestProcessing: kind="image_edit" if route_type == "aimage_edit" else "completion", ) self.data["model"] = resolved_model - # Auth checked the body model; the endpoint model from the URL wins here, so - # authorize the resolved name before any alias rewrite. - if llm_router is not None: + # Auth read the body model (requested_model above). When the endpoint model from + # the URL overrides it, the served model was never checked, so authorize the + # resolved name here, before any alias rewrite. A request whose model came from + # the body was already checked by auth, so it is left alone. + if llm_router is not None and isinstance(model, str) and model and model != requested_model: await can_key_call_requested_models( model=resolved_model, valid_token=user_api_key_dict, llm_router=llm_router ) diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 3112e41c277..0802cb329c5 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -123,13 +123,15 @@ async def image_generation( if isinstance(model, str): reject_url_valued_destination("model", model) + body_model: Final = data.get("model") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # data is an untyped dict body resolved_model: Final = resolve_inference_model( - data.get("model"), general_settings, user_model, model, kind="image_generation" + body_model, general_settings, user_model, model, kind="image_generation" ) data["model"] = resolved_model - # Auth checked the body model; the deployment path model wins here, so - # authorize the resolved name before any alias rewrite. - if llm_router is not None: + # Auth read the body model. When the deployment or query model from the URL + # overrides it, the served model was never checked, so authorize the resolved + # name here, before any alias rewrite. A body model was already checked by auth. + if llm_router is not None and isinstance(model, str) and model and model != body_model: await can_key_call_requested_models( model=resolved_model, valid_token=user_api_key_dict, llm_router=llm_router ) From 8f0eae1c8903d7822cbd241d9e55440abec4a2c5 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar Date: Thu, 29 Oct 2026 03:03:49 +0900 Subject: [PATCH 6/6] fix(proxy): keep basedpyright counts flat in the resolved-model check Compare the endpoint model with a typed local read right before resolve_inference_model instead of the earlier requested_model, whose reuse made basedpyright report the untyped body read again and pushed reportUnknownMemberType past its ceiling. Behavior is unchanged. Tracking issue: https://github.com/BerriAI/litellm/issues/48760 Signed-off-by: Atharva-Kanherkar --- litellm/proxy/common_request_processing.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index a94116ca940..37c36e91bcc 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -2365,19 +2365,20 @@ class ProxyBaseLLMRequestProcessing: if isinstance(model, str): reject_url_valued_destination("model", model) + body_model: Final[object] = self.data.get("model") # pyright: ignore[reportUnknownVariableType] # self.data is an untyped dict body resolved_model: Final = resolve_inference_model( - self.data.get("model"), + body_model, general_settings, user_model, model, kind="image_edit" if route_type == "aimage_edit" else "completion", ) self.data["model"] = resolved_model - # Auth read the body model (requested_model above). When the endpoint model from - # the URL overrides it, the served model was never checked, so authorize the - # resolved name here, before any alias rewrite. A request whose model came from - # the body was already checked by auth, so it is left alone. - if llm_router is not None and isinstance(model, str) and model and model != requested_model: + # Auth read the body model. When the endpoint model from the URL overrides it, + # the served model was never checked, so authorize the resolved name here, before + # any alias rewrite. A request whose model came from the body was already checked + # by auth, so it is left alone. + if llm_router is not None and isinstance(model, str) and model and model != body_model: await can_key_call_requested_models( model=resolved_model, valid_token=user_api_key_dict, llm_router=llm_router )