diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ba1e2632489..a311f407488 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6337,8 +6337,8 @@ class ProxyConfig: model.model_info["db_model"] = True model.model_info["blocked"] = bool(getattr(model, "blocked", False)) - if premium_user is True: - # seeing "created_at", "updated_at", "created_by", "updated_by" is a LiteLLM Enterprise Feature + # Always copy from the DB row, not gated behind premium_user. See #40548 + if model.model_info is not None: model.model_info["created_at"] = getattr(model, "created_at", None) model.model_info["updated_at"] = getattr(model, "updated_at", None) model.model_info["created_by"] = getattr(model, "created_by", None) @@ -14414,6 +14414,17 @@ async def model_info_v2( # Translate `model_name` to the public name for team-scoped rows. all_models = [_translate_model_name_for_response(m) for m in all_models] + if user_api_key_dict.user_role not in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + all_models = [ + {**m, "model_info": {**m["model_info"], "created_by": None, "updated_by": None}} + if isinstance(m.get("model_info"), dict) + else m + for m in all_models + ] + return _paginate_models_response( all_models=all_models, page=page, diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index e79448d0620..92c7508debb 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -12,6 +12,7 @@ import json import logging import os import re +from datetime import datetime, timezone from types import SimpleNamespace from typing import Any, Dict from unittest.mock import AsyncMock, MagicMock @@ -2202,6 +2203,30 @@ def test_ProxyConfig_get_model_info_with_id_missing_model_id_raises(monkeypatch) pc.get_model_info_with_id(model=bad) +@pytest.mark.parametrize("premium_user", [True, False]) +def test_ProxyConfig_get_model_info_with_id_populates_audit_fields_regardless_of_license( + monkeypatch, premium_user +): + """Audit fields must be surfaced on `model_info` regardless of license. See #40548""" + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user) + pc = ProxyConfig() + model = SimpleNamespace( + model_id="m-1", + model_info={"id": "m-1"}, + blocked=False, + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-02T00:00:00Z", + created_by="test-user@example.com", + updated_by="test-user@example.com", + ) + out = pc.get_model_info_with_id(model=model, db_model=True) + dumped = out.model_dump() + assert dumped.get("created_at") == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert dumped.get("updated_at") == datetime(2026, 1, 2, tzinfo=timezone.utc) + assert dumped.get("created_by") == "test-user@example.com" + assert dumped.get("updated_by") == "test-user@example.com" + + # --------------------------------------------------------------------------- # ProxyConfig._delete_deployment # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index 4c141bcf698..d2d613bb104 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -541,3 +541,93 @@ def test_v2_model_info_access_group_paginates_over_the_filtered_set(client, auth assert _model_names(payload) == ["openai/*"] assert payload["total_count"] == 2 assert payload["total_pages"] == 2 + + +# --------------------------------------------------------------------------- +# GET /v2/model/info — created_by/updated_by masking for non-admin callers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def audit_field_router(monkeypatch): + """Router with one audited deployment and one whose model_info isn't a dict.""" + model_list = [ + { + "model_name": "gpt-4-audited", + "litellm_params": {"model": "openai/gpt-4"}, + "model_info": { + "id": "audited-1", + "db_model": True, + "created_by": "alice@example.com", + "updated_by": "bob@example.com", + }, + }, + { + "model_name": "legacy-model", + "litellm_params": {"model": "openai/gpt-3.5-turbo"}, + "model_info": None, + }, + ] + from unittest.mock import AsyncMock + + router = MagicMock() + router.model_list = model_list + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", model_list) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(proxy_server, "user_model", None) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr( + proxy_server, + "_apply_search_filter_to_models", + AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))), + ) + monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model) + + import litellm.proxy.agent_endpoints.model_list_helpers as mlh + + monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models)) + yield router + + +def _model_by_name(payload, name): + return next(m for m in payload["data"] if m["model_name"] == name) + + +def test_v2_model_info_non_admin_masks_created_by_updated_by(client, auth_as, audit_field_router): + """A non-admin caller must not see who created/last touched a model.""" + from litellm.proxy._types import LitellmUserRoles + + with auth_as(role=LitellmUserRoles.INTERNAL_USER): + response = client.get("/v2/model/info") + assert response.status_code == 200 + model = _model_by_name(response.json(), "gpt-4-audited") + assert model["model_info"]["created_by"] is None + assert model["model_info"]["updated_by"] is None + + +@pytest.mark.parametrize( + "role", + ["PROXY_ADMIN", "PROXY_ADMIN_VIEW_ONLY"], +) +def test_v2_model_info_admin_sees_created_by_updated_by(client, auth_as, audit_field_router, role): + """Both admin roles are exempt from the redaction and see the real audit trail.""" + from litellm.proxy._types import LitellmUserRoles + + with auth_as(role=getattr(LitellmUserRoles, role)): + response = client.get("/v2/model/info") + assert response.status_code == 200 + model = _model_by_name(response.json(), "gpt-4-audited") + assert model["model_info"]["created_by"] == "alice@example.com" + assert model["model_info"]["updated_by"] == "bob@example.com" + + +def test_v2_model_info_non_admin_skips_non_dict_model_info(client, auth_as, audit_field_router): + """A row whose model_info isn't a dict must pass through untouched instead of crashing.""" + from litellm.proxy._types import LitellmUserRoles + + with auth_as(role=LitellmUserRoles.INTERNAL_USER): + response = client.get("/v2/model/info") + assert response.status_code == 200 + model = _model_by_name(response.json(), "legacy-model") + assert model["model_info"] is None