diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b7ef927aa48..5cd61119418 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13445,6 +13445,30 @@ async def model_info_v1( "error": f"Model id = {litellm_model_id} not found on litellm proxy" }, ) + # Authorize against user.models — by-id was previously + # short-circuiting before the listing-path filter. + from litellm.proxy.utils import get_available_models_for_user + + authorized_models = await get_available_models_for_user( + user_api_key_dict=user_api_key_dict, + llm_router=llm_router, + general_settings=general_settings, + user_model=user_model, + prisma_client=prisma_client, + proxy_logging_obj=proxy_logging_obj, + team_id=None, + include_model_access_groups=False, + only_model_access_groups=False, + return_wildcard_routes=False, + user_api_key_cache=user_api_key_cache, + ) + if deployment_info.model_name not in authorized_models: + raise HTTPException( + status_code=403, + detail={ + "error": f"Model id = {litellm_model_id} not authorized for this user" + }, + ) _deployment_info_dict = _get_proxy_model_info( model=deployment_info.model_dump(exclude_none=True) ) diff --git a/tests/test_litellm/proxy/discovery_endpoints/test_v1_model_info_user_filter.py b/tests/test_litellm/proxy/discovery_endpoints/test_v1_model_info_user_filter.py index f62abb55181..b4648251be6 100644 --- a/tests/test_litellm/proxy/discovery_endpoints/test_v1_model_info_user_filter.py +++ b/tests/test_litellm/proxy/discovery_endpoints/test_v1_model_info_user_filter.py @@ -236,3 +236,69 @@ def test_v1_model_info_alias_route_filters_identically( resp = client.get("/model/info", headers={"Authorization": "Bearer sk-test"}) assert resp.status_code == 200 assert _model_names(resp.json()) == ["claude-3-opus"] + + +# --------------------------------------------------------------------------- +# /v1/model/info Path A (?litellm_model_id=...) — by-id authorization +# --------------------------------------------------------------------------- + + +def _deployment_id(router, model_name): + for d in router.model_list: + if d["model_name"] == model_name: + return d["model_info"]["id"] + raise AssertionError(f"model {model_name!r} not in router") + + +def test_v1_model_info_by_id_denied_when_model_not_in_user_models( + client, configure_router, monkeypatch +): + """Regression: by-id lookup must respect user.models (veria-ai #29748).""" + _override_auth(user_id="u-test") + _patch_user(monkeypatch, models=["gpt-4"]) + disallowed_id = _deployment_id(configure_router, "claude-3-opus") + + resp = client.get( + f"/v1/model/info?litellm_model_id={disallowed_id}", + headers={"Authorization": "Bearer sk-test"}, + ) + assert resp.status_code == 403 + assert "not authorized" in resp.text.lower() + + +def test_v1_model_info_by_id_allowed_when_model_in_user_models( + client, configure_router, monkeypatch +): + """By-id lookup for a deployment whose model_name is in user.models -> 200.""" + _override_auth(user_id="u-test") + _patch_user(monkeypatch, models=["claude-3-opus"]) + allowed_id = _deployment_id(configure_router, "claude-3-opus") + + resp = client.get( + f"/v1/model/info?litellm_model_id={allowed_id}", + headers={"Authorization": "Bearer sk-test"}, + ) + assert resp.status_code == 200 + assert _model_names(resp.json()) == ["claude-3-opus"] + + +def test_v1_model_info_by_id_master_key_bypass(client, configure_router, monkeypatch): + """Master key / service account (user_id=None) -> by-id lookup unrestricted.""" + + async def _should_not_be_called(*args, **kwargs): + raise AssertionError("get_user_object must not be called when user_id is None") + + monkeypatch.setattr( + "litellm.proxy.auth.auth_checks.get_user_object", + _should_not_be_called, + ) + + _override_auth(user_id=None) + any_id = _deployment_id(configure_router, "claude-3-opus") + + resp = client.get( + f"/v1/model/info?litellm_model_id={any_id}", + headers={"Authorization": "Bearer sk-test"}, + ) + assert resp.status_code == 200 + assert _model_names(resp.json()) == ["claude-3-opus"]