From 462cdd6990f9f07f3190faf1fce33faea7a93226 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 14:47:11 -0700 Subject: [PATCH] fix(router): authorize fusion dependencies --- litellm/router.py | 57 +++++++++++++++++++ tests/test_litellm/test_fusion_router.py | 70 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index 5a1e3be660e..5a3fae75a1d 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -2542,6 +2542,10 @@ class Router: model=model, llm_provider="", ) + await self._authorize_fusion_dependencies( + fusion_router=fusion_router, + request_kwargs=kwargs, + ) response = ( # rebind-ok: one mutually exclusive dispatch branch assigns it await fusion_router.acompletion( messages=messages, @@ -9463,6 +9467,59 @@ class Router: search=self._fusion_asearch, ) + async def _authorize_fusion_dependencies( + self, + fusion_router: FusionRouter, + request_kwargs: Mapping[str, object], + ) -> None: + """Apply the originating proxy caller's model access to every hidden call.""" + metadata_values: Final = tuple(request_kwargs.get(key) for key in ("litellm_metadata", "metadata")) + raw_user_api_key_auth: Final = next( + ( + metadata.get("user_api_key_auth") + for metadata in metadata_values + if isinstance(metadata, Mapping) and metadata.get("user_api_key_auth") is not None + ), + None, + ) + proxy_auth_required: Final = isinstance(request_kwargs.get("proxy_server_request"), Mapping) + if raw_user_api_key_auth is None and not proxy_auth_required: + return + + from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth + from litellm.proxy.auth.auth_checks import can_key_call_resolved_model + + try: + user_api_key_auth: Final = ( + raw_user_api_key_auth + if isinstance(raw_user_api_key_auth, UserAPIKeyAuth) + else UserAPIKeyAuth.model_validate(raw_user_api_key_auth) + ) + except ValidationError as exc: + raise ProxyException( + message="Fusion model authorization context is missing or invalid", + type=ProxyErrorTypes.auth_error, + param="model", + code=403, + ) from exc + + dependency_models: Final = tuple( + dict.fromkeys( + ( + fusion_router.config.outer_model, + *fusion_router.config.panel_models, + fusion_router.config.resolved_analyst_model, + ) + ) + ) + for dependency_model in dependency_models: + await can_key_call_resolved_model( + model=dependency_model, + llm_model_list=self.model_list, + valid_token=user_api_key_auth, + llm_router=self, + ) + async def _fusion_asearch( # kwargs-ok: bridge preserves the Router.asearch keyword surface self, *, diff --git a/tests/test_litellm/test_fusion_router.py b/tests/test_litellm/test_fusion_router.py index b4e1cb96fb4..5c55be9420e 100644 --- a/tests/test_litellm/test_fusion_router.py +++ b/tests/test_litellm/test_fusion_router.py @@ -668,6 +668,76 @@ async def test_router_registers_and_executes_fusion_deployment() -> None: assert "fusion/test" not in router.fusion_routers +@pytest.mark.asyncio +async def test_proxy_fusion_authorizes_every_hidden_model(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth import auth_checks + + model_list = _router_model_list() + model_list.insert( + -1, + { + "model_name": "analyst", + "litellm_params": {"model": "openai/test", "api_key": "fake", "mock_response": "Analysis"}, + }, + ) + model_list[-1]["litellm_params"]["fusion_router_config"]["analyst_model"] = "analyst" + router = Router(model_list=model_list) + authorize = AsyncMock(return_value=None) + monkeypatch.setattr(auth_checks, "can_key_call_resolved_model", authorize) + + response = await router.acompletion( + model="fusion/test", + messages=[{"role": "user", "content": "Answer"}], + metadata={"user_api_key_auth": UserAPIKeyAuth(models=["*"])}, + proxy_server_request={"body": {"model": "fusion/test"}}, + ) + + assert isinstance(response, ModelResponse) + assert [call.kwargs["model"] for call in authorize.await_args_list] == ["outer", "panel-a", "analyst"] + + +@pytest.mark.asyncio +async def test_proxy_fusion_denies_hidden_model_before_any_provider_call(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth + from litellm.proxy.auth import auth_checks + + router = Router(model_list=_router_model_list()) + denial = ProxyException( + message="key not allowed to access model", + type=ProxyErrorTypes.key_model_access_denied, + param="model", + code=403, + ) + monkeypatch.setattr(auth_checks, "can_key_call_resolved_model", AsyncMock(side_effect=denial)) + fusion_completion = AsyncMock() + monkeypatch.setattr(router.fusion_routers["fusion/test"], "acompletion", fusion_completion) + + with pytest.raises(ProxyException, match="key not allowed"): + await router.acompletion( + model="fusion/test", + messages=[{"role": "user", "content": "Answer"}], + metadata={"user_api_key_auth": UserAPIKeyAuth(models=["fusion/test"])}, + proxy_server_request={"body": {"model": "fusion/test"}}, + ) + + fusion_completion.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_proxy_fusion_fails_closed_without_authorization_context() -> None: + from litellm.proxy._types import ProxyException + + router = Router(model_list=_router_model_list()) + + with pytest.raises(ProxyException, match="authorization context is missing or invalid"): + await router.acompletion( + model="fusion/test", + messages=[{"role": "user", "content": "Answer"}], + proxy_server_request={"body": {"model": "fusion/test"}}, + ) + + @pytest.mark.asyncio async def test_router_replays_direct_outer_response_as_an_async_stream() -> None: router = Router(model_list=_router_model_list())