diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index f505406fe0e..8851c26b019 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -1822,6 +1822,7 @@ async def new_team(
"tpm_limit",
"rpm_limit",
"team_member_permissions",
+ "access_group_ids",
):
if getattr(data, field, None) is None and (
field != "budget_duration" or field not in data.model_fields_set
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index 6d31fa3f226..6b56c513c5f 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -51,6 +51,7 @@ from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
+ AccessGroupRepository,
SSOConfigRepository,
UISettingsRepository,
)
@@ -1169,6 +1170,39 @@ async def update_internal_user_settings(
)
+async def _validate_default_access_groups_exist(access_group_ids: list[str]) -> None:
+ """Reject default access groups that cannot be attached.
+
+ Teams are created from these settings long after they are saved, and /team/new does
+ not check that an access group exists, so an unknown id would silently attach nothing
+ to every future team instead of failing here, where the admin can still fix it.
+ """
+ if not access_group_ids:
+ return
+
+ from litellm.proxy.proxy_server import prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": "Database not connected. Please connect a database."},
+ )
+
+ existing_groups: Final = await AccessGroupRepository(prisma_client).table.find_many(
+ where={"access_group_id": {"in": list(dict.fromkeys(access_group_ids))}}
+ )
+ existing_ids: Final = {group.access_group_id for group in existing_groups}
+ missing_ids: Final = tuple(dict.fromkeys(ag_id for ag_id in access_group_ids if ag_id not in existing_ids))
+ if missing_ids:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Access group(s) not found: {', '.join(missing_ids)}. "
+ "Create the access group first or remove it from the default team settings."
+ },
+ )
+
+
@router.patch(
"/update/default_team_settings",
tags=["SSO Settings"],
@@ -1185,6 +1219,8 @@ async def update_default_team_settings(
"""
if settings.organization_id is not None:
await _validate_default_organization_exists(settings.organization_id)
+ if settings.access_group_ids is not None:
+ await _validate_default_access_groups_exist(settings.access_group_ids)
return await _update_litellm_setting(
settings=settings,
diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py
index affb6d2e9ce..e7ccb2d4aad 100644
--- a/litellm/types/proxy/management_endpoints/ui_sso.py
+++ b/litellm/types/proxy/management_endpoints/ui_sso.py
@@ -234,3 +234,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
default=None,
description="Default organization for new teams created without an explicit organization",
)
+ access_group_ids: list[str] | None = Field(
+ default=None,
+ description="Default access groups attached to new teams created without explicit access_group_ids",
+ )
diff --git a/tests/unit/proxy/management_endpoints/test_team_default_params.py b/tests/unit/proxy/management_endpoints/test_team_default_params.py
index cf4961dc82b..8a9030db7f2 100644
--- a/tests/unit/proxy/management_endpoints/test_team_default_params.py
+++ b/tests/unit/proxy/management_endpoints/test_team_default_params.py
@@ -214,6 +214,40 @@ class TestNewTeamDefaultParamsApplied:
assert data.rpm_limit == 500
assert data.team_member_permissions == ["/key/generate", "/key/update"]
+ @pytest.mark.asyncio
+ async def test_default_access_group_ids_attach_to_a_team_created_without_any(self, monkeypatch):
+ """A central access group configured in default_team_params lands on every
+ /team/new call that omits access_group_ids, not only on SSO-created teams."""
+ from litellm.proxy.management_endpoints.team_endpoints import new_team
+
+ monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
+
+ data = NewTeamRequest(team_alias="my-team")
+
+ try:
+ await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock())
+ except Exception:
+ pass
+
+ assert data.access_group_ids == ["central-models"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("explicit_access_group_ids", [["research"], []])
+ async def test_explicit_access_group_ids_win_over_the_default(self, monkeypatch, explicit_access_group_ids):
+ """An explicit list, including an empty one, is the caller's choice and is kept."""
+ from litellm.proxy.management_endpoints.team_endpoints import new_team
+
+ monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
+
+ data = NewTeamRequest(team_alias="my-team", access_group_ids=explicit_access_group_ids)
+
+ try:
+ await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock())
+ except Exception:
+ pass
+
+ assert data.access_group_ids == explicit_access_group_ids
+
@pytest.mark.asyncio
async def test_explicit_values_not_overridden(self, monkeypatch):
"""When request provides explicit values, defaults do not override them."""
diff --git a/tests/unit/proxy/management_endpoints/test_ui_sso.py b/tests/unit/proxy/management_endpoints/test_ui_sso.py
index ba78fefd653..59179d50b79 100644
--- a/tests/unit/proxy/management_endpoints/test_ui_sso.py
+++ b/tests/unit/proxy/management_endpoints/test_ui_sso.py
@@ -727,10 +727,15 @@ def test_get_group_ids_from_graph_api_response():
[
# Test case 1: Using DefaultTeamSSOParams
DefaultTeamSSOParams(
- max_budget=10, budget_duration="1d", models=["special-gpt-5"]
+ max_budget=10, budget_duration="1d", models=["special-gpt-5"], access_group_ids=["central-models"]
),
# Test case 2: Using Dict
- {"max_budget": 10, "budget_duration": "1d", "models": ["special-gpt-5"]},
+ {
+ "max_budget": 10,
+ "budget_duration": "1d",
+ "models": ["special-gpt-5"],
+ "access_group_ids": ["central-models"],
+ },
],
)
async def test_default_team_params(team_params):
@@ -775,6 +780,7 @@ async def test_default_team_params(team_params):
assert create_call_args["max_budget"] == 10
assert create_call_args["budget_duration"] == "1d"
assert create_call_args["models"] == ["special-gpt-5"]
+ assert create_call_args["access_group_ids"] == ["central-models"]
@pytest.mark.asyncio
diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index e66050877df..59c88fb3979 100644
--- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -3458,6 +3458,86 @@ def test_update_default_team_settings_without_organization_skips_lookup(
assert mock_proxy_config["save_call_count"]() == 1
+@pytest.fixture
+def mock_access_group_lookup(monkeypatch):
+ """Fake the access group table so the default team settings endpoint can check ids."""
+ from unittest.mock import AsyncMock, MagicMock
+
+ import litellm
+ import litellm.proxy.proxy_server as proxy_server_module
+
+ existing_access_group_ids: set = set()
+
+ async def _find_many(where):
+ return [
+ MagicMock(access_group_id=access_group_id)
+ for access_group_id in where["access_group_id"]["in"]
+ if access_group_id in existing_access_group_ids
+ ]
+
+ find_many = AsyncMock(side_effect=_find_many)
+ fake_prisma = MagicMock()
+ fake_prisma.db.litellm_accessgrouptable.find_many = find_many
+
+ monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
+ monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
+ monkeypatch.setattr(litellm, "default_team_params", {})
+
+ return {"existing_access_group_ids": existing_access_group_ids, "find_many": find_many}
+
+
+def test_update_default_team_settings_rejects_unknown_access_group(mock_proxy_config, mock_auth, mock_access_group_lookup):
+ """/team/new never checks that an access group exists, so an unknown default id
+ would silently attach nothing to every future team. Catch it where the admin typed it."""
+ mock_access_group_lookup["existing_access_group_ids"].add("real-group")
+
+ resp = client.patch(
+ "/update/default_team_settings",
+ json={"access_group_ids": ["real-group", "ghost-group"]},
+ )
+
+ assert resp.status_code == 400, resp.text
+ assert "ghost-group" in resp.json()["detail"]["error"]
+ assert "real-group" not in resp.json()["detail"]["error"]
+ assert mock_proxy_config["save_call_count"]() == 0
+
+ import litellm
+
+ assert litellm.default_team_params == {}
+
+
+def test_update_default_team_settings_saves_existing_access_groups(mock_proxy_config, mock_auth, mock_access_group_lookup):
+ """Real access group ids save and reach the in-memory settings /team/new reads."""
+ mock_access_group_lookup["existing_access_group_ids"].update({"real-group", "other-group"})
+
+ resp = client.patch(
+ "/update/default_team_settings",
+ json={"access_group_ids": ["real-group", "other-group"]},
+ )
+
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["settings"]["access_group_ids"] == ["real-group", "other-group"]
+ assert mock_proxy_config["save_call_count"]() == 1
+
+ import litellm
+
+ assert litellm.default_team_params["access_group_ids"] == ["real-group", "other-group"]
+
+
+def test_update_default_team_settings_with_empty_access_groups_skips_lookup(
+ mock_proxy_config, mock_auth, mock_access_group_lookup
+):
+ """Clearing the default access groups must not pay for a DB round trip."""
+ resp = client.patch(
+ "/update/default_team_settings",
+ json={"access_group_ids": []},
+ )
+
+ assert resp.status_code == 200, resp.text
+ mock_access_group_lookup["find_many"].assert_not_awaited()
+ assert mock_proxy_config["save_call_count"]() == 1
+
+
def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
"""Non-admin callers must not mutate global MCP semantic filter settings."""
from litellm.proxy._types import UserAPIKeyAuth
diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx
index 4a042c32cda..5a4ce705b82 100644
--- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx
@@ -89,6 +89,32 @@ vi.mock("./common_components/OrganizationDropdown", () => ({
),
}));
+vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
+ useAccessGroups: () => ({
+ data: [
+ { access_group_id: "ag-1", access_group_name: "Central Models" },
+ { access_group_id: "ag-2", access_group_name: "Research" },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("./common_components/AccessGroupSelector", () => ({
+ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
+
+ ),
+}));
+
vi.mock("./ModelSelect/ModelSelect", () => {
const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (