diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 469a017affc..dfcc0ff6bbf 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1010,6 +1010,31 @@ async def new_team( # noqa: PLR0915 }, ) + # Apply defaults from litellm.default_team_params for any fields + # not explicitly provided in the request. This runs before the org + # limit checks below so inherited defaults (e.g. models) are validated + # against the organization's allowlist like explicit values. + for field in ( + "max_budget", + "budget_duration", + "tpm_limit", + "rpm_limit", + "team_member_permissions", + ): + if getattr(data, field, None) is None: + default_value = _get_default_team_param(field) + if default_value is not None: + setattr(data, field, default_value) + + # `models` defaults to an empty list rather than None on the request, so + # use model_fields_set to distinguish "field omitted" (apply the default, + # matching SSO-provisioned team behavior) from an explicit `models: []` + # (meaning unrestricted access, which must be preserved). + if "models" not in data.model_fields_set: + default_models = _get_default_team_param("models") + if default_models is not None: + data.models = default_models + # check org key limits - done here to handle inheriting org id from team if data.organization_id is not None and prisma_client is not None: org_table = await get_org_object( @@ -1029,28 +1054,6 @@ async def new_team( # noqa: PLR0915 prisma_client=prisma_client, ) - # Apply defaults from litellm.default_team_params for any fields - # not explicitly provided in the request. - for field in ( - "max_budget", - "budget_duration", - "tpm_limit", - "rpm_limit", - "team_member_permissions", - ): - if getattr(data, field, None) is None: - default_value = _get_default_team_param(field) - if default_value is not None: - setattr(data, field, default_value) - - # `models` defaults to an empty list rather than None on the request, so it - # needs its own check: apply the configured default when the request did not - # specify any models (matches SSO-provisioned team behavior). - if not getattr(data, "models", None): - default_models = _get_default_team_param("models") - if default_models is not None: - data.models = default_models - # Legacy fallback: apply max_budget from default_team_settings (YAML config) # if still not set after checking default_team_params. if data.max_budget is None: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py index 33f5f15091c..6ed1a618456 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -342,6 +342,32 @@ class TestNewTeamDefaultParamsApplied: assert data.models == ["claude-3-5-sonnet"] + @pytest.mark.asyncio + async def test_models_explicit_empty_list_not_overridden(self, monkeypatch): + """An explicit `models: []` means unrestricted access and must be preserved, + distinguished from an omitted field via model_fields_set.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + {"models": ["gpt-4"]}, + ) + + data = NewTeamRequest(team_alias="my-team", models=[]) + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.models == [] + @pytest.mark.asyncio async def test_no_defaults_when_config_is_none(self, monkeypatch): """When default_team_params is None, no defaults applied."""