feat(teams): apply default organization to new teams from default team settings (#35540)

* feat(teams): apply default organization to new teams from default team settings

Adds organization_id to DefaultTeamSSOParams so proxy admins can pick a
default organization in Default Team Settings. new_team applies it before
org validation whenever a team is created without an explicit
organization_id, so API, Admin UI, SCIM, SSO, and team upsert creations
all inherit it and go through the same existence and org-limit checks.
Explicit organization selections win and existing teams are untouched.

The default is validated at save time (PATCH /update/default_team_settings
returns 400 for an unknown org) and at create time, where a missing org now
surfaces as a clean 400 instead of a 500 by routing OrganizationNotFoundError
into the previously dead org_table None guard.

The Admin UI Default Team Settings tab gets a Default Organization row
backed by the shared OrganizationDropdown.

* fix(teams): validate org limits against final team state including defaults

Applies default_team_params and the legacy max_budget fallback before the
organization validation block, so _check_org_team_limits sees the values the
team will actually be persisted with. Also loads the org's budget table in
the lookup; without include_budget_table every budget comparison in
_check_org_team_limits was skipped because litellm_budget_table was None.

* test(proxy_behavior): pin org team limits as enforced on /team/new

The dead-code pins existed to turn red when include_budget_table went
live; that happened, so the scenarios now assert the 400 rejections plus
within-cap acceptance, and the unknown-org pin asserts the handler's 400
instead of the surfaced 500.
This commit is contained in:
ryan-crabbe-berri 2026-08-03 12:57:12 -07:00 • committed by GitHub
parent 41e4408906
commit 46b6eae799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 656 additions and 148 deletions

View file

@ -76,6 +76,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import (
OrganizationNotFoundError,
_cache_team_object,
allowed_route_check_inside_route,
can_org_access_model,
@ -1210,24 +1211,10 @@ async def new_team(
detail={"error": f"Team id = {data.team_id} already exists. Please use a different team id."},
)
# 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(
org_id=data.organization_id,
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
)
if org_table is None:
raise HTTPException(
status_code=400,
detail=f"Organization not found for organization_id={data.organization_id}",
)
await _check_org_team_limits(
org_table=org_table,
data=data,
prisma_client=prisma_client,
)
if data.organization_id is None:
default_organization_id = _get_default_team_param("organization_id")
if isinstance(default_organization_id, str):
data.organization_id = default_organization_id
# Apply defaults from litellm.default_team_params for any fields
# not explicitly provided in the request.
@ -1255,6 +1242,29 @@ async def new_team(
if default_budget is not None:
data.max_budget = default_budget
# 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:
try:
org_table = await get_org_object(
org_id=data.organization_id,
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
include_budget_table=True,
)
except OrganizationNotFoundError:
org_table = None
if org_table is None:
raise HTTPException(
status_code=400,
detail=f"Organization not found for organization_id={data.organization_id}",
)
await _check_org_team_limits(
org_table=org_table,
data=data,
prisma_client=prisma_client,
)
if (
user_api_key_dict.user_role is None or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
): # don't restrict proxy admin

View file

@ -23,6 +23,7 @@ from litellm.proxy.config_resolvers.sso import (
)
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.table_repositories import (
SSOConfigRepository,
UISettingsRepository,
@ -636,6 +637,36 @@ async def _validate_default_teams_exist(teams: list[str] | list[NewUserRequestTe
)
async def _validate_default_organization_exists(organization_id: str) -> None:
"""Reject a default organization that cannot be assigned.
Teams are created from these settings long after they are saved, and an unknown
organization id would fail every future team creation instead of here, where the
admin who typed it can still fix it.
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
"error": "Database not connected. Please connect a database."
},
)
organization_exists = await OrganizationRepository(prisma_client).exists(
organization_id, id_field="organization_id"
)
if not organization_exists:
raise HTTPException(
status_code=400,
detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
"error": f"Organization not found: {organization_id}. "
"An organization must exist before it can be set as the default organization for new teams."
},
)
async def update_default_team_member_budget(teams: list[NewUserRequestTeam], user_api_key_dict: UserAPIKeyAuth):
"""
1. Update the max member budget for the team
@ -774,6 +805,9 @@ async def update_default_team_settings(
Update the default team parameters for SSO users.
These settings will be applied to new teams created from SSO.
"""
if settings.organization_id is not None:
await _validate_default_organization_exists(settings.organization_id)
return await _update_litellm_setting(
settings=settings,
settings_key="default_team_params",

View file

@ -229,3 +229,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
default=None,
description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.",
)
organization_id: str | None = Field(
default=None,
description="Default organization for new teams created without an explicit organization",
)

View file

@ -10,14 +10,14 @@ Pins the five helpers
Driven through /team/new + /team/update.
Structural finding pinned here, identical in shape to F1's org aggregate:
both call sites (lines 985 + 1751) load the org via `get_org_object`
WITHOUT `include_budget_table=True`, so `org_table.litellm_budget_table`
is `None` and the org max_budget / org tpm / org rpm guards inside
`_check_org_team_limits` (lines 641–694, 670–694) silently no-op. The
`models` subset guard (lines 654–667) IS reachable because it reads
`org_table.models` directly. The `_check_user_team_limits` guards reach
all branches through `user_api_key_dict`, no relation include needed.
Structural finding, updated: /team/new loads the org via `get_org_object`
WITH `include_budget_table=True`, so the org max_budget / org tpm / org rpm
guards inside `_check_org_team_limits` are live there and are pinned as
enforced below. /team/update still loads the org without the budget
relation, so its budget guards remain no-ops. The `models` subset guard IS
reachable on both because it reads `org_table.models` directly. The
`_check_user_team_limits` guards reach all branches through
`user_api_key_dict`, no relation include needed.
"""
import uuid
@ -132,48 +132,67 @@ async def test_check_org_team_limits_models_subset(
headers={"Authorization": f"Bearer {seeder}"},
json=body,
)
assert (
resp.status_code == expected_status
), f"{body!r} → {resp.status_code}: {resp.text}"
assert resp.status_code == expected_status, f"{body!r} → {resp.status_code}: {resp.text}"
rows = await prisma.db.litellm_teamtable.find_many(where={"team_id": team_id})
assert len(rows) == (1 if expected_status == 200 else 0)
# ---------------------------------------------------------------------------
# _check_org_team_limits — budget / tpm / rpm structurally unreachable
# (org_table.litellm_budget_table is None at guard time). Pin the
# no-op behavior so a future change that flips include_budget_table=True
# turns these into reds.
# _check_org_team_limits — budget / tpm / rpm live on /team/new since its
# get_org_object call passes include_budget_table=True. (/team/update still
# loads the org without the budget relation, so its guards remain no-ops.)
# ---------------------------------------------------------------------------
_ORG_BUDGET_DEAD_SCENARIOS = [
_ORG_BUDGET_ENFORCED_SCENARIOS = [
(
"org_budget/over_max_budget_unenforced",
"org_budget/over_max_budget_rejected",
{"max_budget": 100, "tpm_limit": None, "rpm_limit": None},
{"max_budget": 999_999},
400,
),
(
"org_tpm/over_unenforced",
"org_budget/within_max_budget_accepted",
{"max_budget": 100, "tpm_limit": None, "rpm_limit": None},
{"max_budget": 50},
200,
),
(
"org_tpm/over_rejected",
{"max_budget": None, "tpm_limit": 100, "rpm_limit": None},
{"tpm_limit": 999_999},
400,
),
(
"org_rpm/over_unenforced",
"org_tpm/within_accepted",
{"max_budget": None, "tpm_limit": 100, "rpm_limit": None},
{"tpm_limit": 50},
200,
),
(
"org_rpm/over_rejected",
{"max_budget": None, "tpm_limit": None, "rpm_limit": 100},
{"rpm_limit": 999_999},
400,
),
(
"org_rpm/within_accepted",
{"max_budget": None, "tpm_limit": None, "rpm_limit": 100},
{"rpm_limit": 50},
200,
),
]
@pytest.mark.parametrize(
"org_budget,body_extras",
[(b, c) for (_id, b, c) in _ORG_BUDGET_DEAD_SCENARIOS],
ids=[s[0] for s in _ORG_BUDGET_DEAD_SCENARIOS],
"org_budget,body_extras,expected_status",
[(b, c, d) for (_id, b, c, d) in _ORG_BUDGET_ENFORCED_SCENARIOS],
ids=[s[0] for s in _ORG_BUDGET_ENFORCED_SCENARIOS],
)
async def test_check_org_team_limits_budget_dead_code_pin(
async def test_check_org_team_limits_budget_enforced(
org_budget,
body_extras: Dict[str, Any],
expected_status: int,
proxy_client,
prisma,
scratch,
@ -192,9 +211,9 @@ async def test_check_org_team_limits_budget_dead_code_pin(
**body_extras,
},
)
assert resp.status_code == 200, resp.text
assert resp.status_code == expected_status, f"{body_extras!r} → {resp.status_code}: {resp.text}"
rows = await prisma.db.litellm_teamtable.find_many(where={"team_id": team_id})
assert len(rows) == 1
assert len(rows) == (1 if expected_status == 200 else 0)
# ---------------------------------------------------------------------------
@ -279,9 +298,9 @@ async def test_check_user_team_limits(
**body_extras,
},
)
assert (
resp.status_code == expected_status
), f"caps={actor_caps} body={body_extras} → {resp.status_code}: {resp.text}"
assert resp.status_code == expected_status, (
f"caps={actor_caps} body={body_extras} → {resp.status_code}: {resp.text}"
)
rows = await prisma.db.litellm_teamtable.find_many(where={"team_id": team_id})
assert len(rows) == (1 if expected_status == 200 else 0)
@ -376,9 +395,7 @@ async def test_proxy_admin_raise_budget_allowed(proxy_client, prisma, scratch):
async def test_team_admin_remove_budget_cap_blocked(proxy_client, prisma, scratch):
"""A team admin cannot strip the team's cap (max_budget=null); removing the
ceiling is the strongest possible raise -> proxy-admin only."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma, scratch.prefix, max_budget=100000.0
)
caller_cleartext = await _seed_scratch_actor_with_caps(prisma, scratch.prefix, max_budget=100000.0)
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),

View file

@ -72,13 +72,9 @@ async def test_team_new_authz_matrix(
headers={"Authorization": f"Bearer {caller.cleartext}"},
json=body,
)
assert (
resp.status_code == expected_status
), f"{actor.value} org={org_target}: {resp.status_code} {resp.text}"
assert resp.status_code == expected_status, f"{actor.value} org={org_target}: {resp.status_code} {resp.text}"
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
if expected_status == 200:
assert row is not None
assert row.organization_id == org_id
@ -94,9 +90,7 @@ async def test_team_new_rejects_negative_budget(proxy_client, prisma, scratch, w
json={"team_id": scratch.prefix, "max_budget": -1},
)
assert resp.status_code == 400, resp.text
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is None
@ -118,12 +112,10 @@ async def test_team_new_rejects_duplicate_team_id(proxy_client, prisma, scratch,
assert second.status_code == 400, second.text
async def test_team_new_unknown_organization_is_500(
proxy_client, prisma, scratch, world
):
"""SURFACED, NOT ENDORSED: a /team/new with an organization_id that does
not exist currently fails 500 (the role-resolution layer raises before
the handler's own 400 'Organization not found' check is reached)."""
async def test_team_new_unknown_organization_is_400(proxy_client, prisma, scratch, world):
"""A /team/new with an organization_id that does not exist fails 400:
OrganizationNotFoundError is routed into the handler's own
'Organization not found' guard instead of escaping as a 500."""
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
@ -132,8 +124,7 @@ async def test_team_new_unknown_organization_is_500(
"organization_id": scratch.tag("no-such-org"),
},
)
assert resp.status_code == 500, resp.text
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
assert resp.status_code == 400, resp.text
assert "Organization not found" in resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix})
assert row is None

View file

@ -10,13 +10,14 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
sys.path.insert(
0, os.path.abspath("../../../")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../../")) # Adds the parent directory to the system path
import litellm
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
NewTeamRequest,
ProxyException,
UserAPIKeyAuth,
LitellmUserRoles,
)
@ -76,9 +77,7 @@ class TestConfigFieldsDefaultTeamParams:
db_param_value=db_settings,
)
assert result["litellm_settings"]["default_team_params"] == {
"max_budget": 100.0
}
assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0}
# Existing keys preserved
assert result["litellm_settings"]["cache"] is False
@ -172,6 +171,22 @@ class TestNewTeamDefaultParamsApplied:
user_role=LitellmUserRoles.PROXY_ADMIN,
)
def _make_org(self, organization_id: str, max_budget: float | None = None) -> LiteLLM_OrganizationTable:
return LiteLLM_OrganizationTable(
organization_id=organization_id,
budget_id="budget-id",
created_by="admin-user",
updated_by="admin-user",
litellm_budget_table=None if max_budget is None else LiteLLM_BudgetTable(max_budget=max_budget),
)
def _patch_org_lookup(self, monkeypatch, **mock_kwargs) -> AsyncMock:
from litellm.proxy.management_endpoints import team_endpoints
lookup = AsyncMock(**mock_kwargs)
monkeypatch.setattr(team_endpoints, "get_org_object", lookup)
return lookup
@pytest.mark.asyncio
async def test_all_defaults_applied_when_not_provided(self, monkeypatch):
"""When no budget/rate/permission fields are in the request, all defaults apply."""
@ -312,6 +327,7 @@ class TestNewTeamDefaultParamsApplied:
assert data.tpm_limit is None
assert data.rpm_limit is None
assert data.team_member_permissions is None
assert data.organization_id is None
@pytest.mark.asyncio
async def test_legacy_default_team_settings_fallback(self, monkeypatch):
@ -370,6 +386,144 @@ class TestNewTeamDefaultParamsApplied:
# default_team_params wins (100.0), legacy fallback (999.0) not used
assert data.max_budget == 100.0
@pytest.mark.asyncio
async def test_default_organization_applied_and_validated(self, monkeypatch):
"""The default org must land before the org-validation block, so a defaulted
org goes through the same existence + org-limit checks as an explicit one."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", {"organization_id": "default-org"})
org_lookup = self._patch_org_lookup(monkeypatch, return_value=self._make_org("default-org"))
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.organization_id == "default-org"
org_lookup.assert_awaited_once()
assert org_lookup.await_args.kwargs["org_id"] == "default-org"
@pytest.mark.asyncio
async def test_explicit_organization_wins_over_default(self, monkeypatch):
"""An organization_id in the request must not be replaced by the default."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", {"organization_id": "default-org"})
org_lookup = self._patch_org_lookup(monkeypatch, return_value=self._make_org("explicit-org"))
data = NewTeamRequest(team_alias="my-team", organization_id="explicit-org")
try:
await new_team(
data=data,
user_api_key_dict=self._make_admin_auth(),
http_request=MagicMock(),
)
except Exception:
pass
assert data.organization_id == "explicit-org"
assert org_lookup.await_args.kwargs["org_id"] == "explicit-org"
@pytest.mark.asyncio
async def test_nonexistent_default_organization_returns_400(self, monkeypatch):
"""get_org_object raises instead of returning None, so an org that no longer
exists surfaced as a 500; team creation must report a 400 instead."""
from litellm.proxy.auth.auth_checks import OrganizationNotFoundError
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", {"organization_id": "deleted-org"})
self._patch_org_lookup(
monkeypatch,
side_effect=OrganizationNotFoundError("Organization doesn't exist in db. Organization=deleted-org"),
)
with pytest.raises(ProxyException) as exc_info:
await new_team(
data=NewTeamRequest(team_alias="my-team"),
user_api_key_dict=self._make_admin_auth(),
http_request=MagicMock(),
)
assert exc_info.value.code == "400"
assert "deleted-org" in exc_info.value.message
@pytest.mark.asyncio
async def test_defaulted_max_budget_validated_against_org_budget(self, monkeypatch):
"""Defaults must be applied BEFORE _check_org_team_limits runs, or a default
max_budget above the org's cap is persisted unchecked."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{"organization_id": "capped-org", "max_budget": 500.0},
)
self._patch_org_lookup(monkeypatch, return_value=self._make_org("capped-org", max_budget=100.0))
with pytest.raises(ProxyException) as exc_info:
await new_team(
data=NewTeamRequest(team_alias="my-team"),
user_api_key_dict=self._make_admin_auth(),
http_request=MagicMock(),
)
assert exc_info.value.code == "400"
assert "exceeds organization's max_budget" in exc_info.value.message
@pytest.mark.asyncio
async def test_explicit_budget_validated_against_default_org_budget(self, monkeypatch):
"""The org lookup must load the budget table (include_budget_table=True);
without it litellm_budget_table is None and every budget comparison is skipped."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", {"organization_id": "capped-org"})
org_lookup = self._patch_org_lookup(monkeypatch, return_value=self._make_org("capped-org", max_budget=100.0))
with pytest.raises(ProxyException) as exc_info:
await new_team(
data=NewTeamRequest(team_alias="my-team", max_budget=500.0),
user_api_key_dict=self._make_admin_auth(),
http_request=MagicMock(),
)
assert exc_info.value.code == "400"
assert "exceeds organization's max_budget" in exc_info.value.message
assert org_lookup.await_args.kwargs["include_budget_table"] is True
@pytest.mark.asyncio
async def test_defaults_within_org_budget_still_created(self, monkeypatch):
"""A default budget under the org cap must not be rejected by the reordered check."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{"organization_id": "capped-org", "max_budget": 50.0},
)
self._patch_org_lookup(monkeypatch, return_value=self._make_org("capped-org", max_budget=100.0))
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.organization_id == "capped-org"
assert data.max_budget == 50.0
# ---------------------------------------------------------------------------
# _update_litellm_setting: setattr ordering
@ -536,18 +690,12 @@ class TestBulkUpdateTeamMemberPermissions:
mock_batcher.commit = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[team_a, team_b]
)
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_a, team_b])
mock_prisma.db.batch_ = MagicMock(return_value=mock_batcher)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], apply_to_all_teams=True
)
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=["/team/daily/activity"], apply_to_all_teams=True)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 2
calls = mock_batcher.litellm_teamtable.update.call_args_list
@ -555,19 +703,14 @@ class TestBulkUpdateTeamMemberPermissions:
team_a_call = [c for c in calls if c.kwargs["where"]["team_id"] == "team-a"][0]
assert "/key/generate" in team_a_call.kwargs["data"]["team_member_permissions"]
assert (
"/team/daily/activity"
in team_a_call.kwargs["data"]["team_member_permissions"]
)
assert "/team/daily/activity" in team_a_call.kwargs["data"]["team_member_permissions"]
team_b_call = [c for c in calls if c.kwargs["where"]["team_id"] == "team-b"][0]
assert "/key/delete" in team_b_call.kwargs["data"]["team_member_permissions"]
assert "/key/update" in team_b_call.kwargs["data"]["team_member_permissions"]
@pytest.mark.asyncio
async def test_all_teams_skips_teams_that_already_have_permission(
self, monkeypatch
):
async def test_all_teams_skips_teams_that_already_have_permission(self, monkeypatch):
"""apply_to_all_teams: teams that already have the permission are skipped."""
from litellm.proxy.management_endpoints.team_endpoints import (
bulk_update_team_member_permissions,
@ -583,18 +726,12 @@ class TestBulkUpdateTeamMemberPermissions:
mock_batcher.commit = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[team_has, team_missing]
)
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_has, team_missing])
mock_prisma.db.batch_ = MagicMock(return_value=mock_batcher)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], apply_to_all_teams=True
)
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=["/team/daily/activity"], apply_to_all_teams=True)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 1
calls = mock_batcher.litellm_teamtable.update.call_args_list
@ -618,18 +755,12 @@ class TestBulkUpdateTeamMemberPermissions:
mock_batcher.commit = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(
side_effect=[page1, page2]
)
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(side_effect=[page1, page2])
mock_prisma.db.batch_ = MagicMock(return_value=mock_batcher)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], apply_to_all_teams=True
)
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=["/team/daily/activity"], apply_to_all_teams=True)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 502
find_calls = mock_prisma.db.litellm_teamtable.find_many.call_args_list
@ -656,18 +787,14 @@ class TestBulkUpdateTeamMemberPermissions:
mock_batcher.commit = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[team_a, team_b]
)
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_a, team_b])
mock_prisma.db.batch_ = MagicMock(return_value=mock_batcher)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], team_ids=["team-a", "team-b"]
)
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 2
@ -692,18 +819,14 @@ class TestBulkUpdateTeamMemberPermissions:
mock_batcher.commit = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[team_has, team_missing]
)
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_has, team_missing])
mock_prisma.db.batch_ = MagicMock(return_value=mock_batcher)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], team_ids=["team-has", "team-missing"]
)
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 1
calls = mock_batcher.litellm_teamtable.update.call_args_list
@ -731,9 +854,7 @@ class TestBulkUpdateTeamMemberPermissions:
)
with pytest.raises(HTTPException) as exc_info:
await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert exc_info.value.status_code == 404
assert "team-b" in str(exc_info.value.detail)
@ -753,14 +874,10 @@ class TestBulkUpdateTeamMemberPermissions:
mock_prisma = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"]
)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=["/team/daily/activity"])
with pytest.raises(HTTPException) as exc_info:
await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert exc_info.value.status_code == 400
@ -784,9 +901,7 @@ class TestBulkUpdateTeamMemberPermissions:
)
with pytest.raises(HTTPException) as exc_info:
await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert exc_info.value.status_code == 400
@ -804,9 +919,7 @@ class TestBulkUpdateTeamMemberPermissions:
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=[])
result = await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._admin_key_dict()
)
result = await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._admin_key_dict())
assert result["teams_updated"] == 0
mock_prisma.db.litellm_teamtable.find_many.assert_not_called()
@ -824,14 +937,10 @@ class TestBulkUpdateTeamMemberPermissions:
mock_prisma = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
data = BulkUpdateTeamMemberPermissionsRequest(
permissions=["/team/daily/activity"], apply_to_all_teams=True
)
data = BulkUpdateTeamMemberPermissionsRequest(permissions=["/team/daily/activity"], apply_to_all_teams=True)
with pytest.raises(HTTPException) as exc_info:
await bulk_update_team_member_permissions(
data=data, user_api_key_dict=self._non_admin_key_dict()
)
await bulk_update_team_member_permissions(data=data, user_api_key_dict=self._non_admin_key_dict())
assert exc_info.value.status_code == 403
@ -844,6 +953,4 @@ class TestBulkUpdateTeamMemberPermissions:
)
with pytest.raises(ValidationError):
BulkUpdateTeamMemberPermissionsRequest(
permissions=["/not/a/real/permission"]
)
BulkUpdateTeamMemberPermissionsRequest(permissions=["/not/a/real/permission"])

View file

@ -606,6 +606,55 @@ async def test_default_team_params(team_params):
assert create_call_args["models"] == ["special-gpt-5"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"team_params",
[
DefaultTeamSSOParams(max_budget=10, budget_duration="1d", organization_id="default-org"),
{"max_budget": 10, "budget_duration": "1d", "organization_id": "default-org"},
],
)
async def test_default_team_params_organization_id_reaches_sso_created_team(team_params):
"""The SSO auto-team path builds NewTeamRequest straight from default_team_params,
so a default organization_id must land on the created team row and be validated."""
from litellm.proxy._types import LiteLLM_OrganizationTable
litellm.default_team_params = team_params
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_first = AsyncMock(return_value=None)
mock_prisma.db.litellm_teamtable.create = AsyncMock()
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_prisma.get_data = AsyncMock(return_value=None)
mock_prisma.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
mock_org = LiteLLM_OrganizationTable(
organization_id="default-org",
budget_id="budget-id",
created_by="admin",
updated_by="admin",
)
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch(
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
AsyncMock(return_value=mock_org),
) as mock_get_org:
team_id = str(uuid.uuid4())
await MicrosoftSSOHandler.create_litellm_teams_from_service_principal_team_ids(
service_principal_teams=[
MicrosoftServicePrincipalTeam(
principalId=team_id,
principalDisplayName="Test Team",
)
]
)
mock_prisma.db.litellm_teamtable.create.assert_called_once()
create_call_args = mock_prisma.db.litellm_teamtable.create.call_args.kwargs["data"]
assert create_call_args["organization_id"] == "default-org"
assert mock_get_org.call_args.kwargs["org_id"] == "default-org"
@pytest.mark.asyncio
async def test_create_team_without_default_params():
"""

View file

@ -2816,6 +2816,94 @@ def test_update_internal_user_settings_without_teams_skips_team_lookup(mock_prox
assert mock_proxy_config["save_call_count"]() == 1
@pytest.fixture
def mock_organization_lookup(monkeypatch):
"""Back /update/default_team_settings with a fake organization table.
Yields the set of organization ids that exist; the test mutates it before the call.
"""
from unittest.mock import AsyncMock, MagicMock
import litellm
import litellm.proxy.proxy_server as proxy_server_module
existing_organization_ids: set = set()
async def _find_unique(where):
organization_id = where["organization_id"]
if organization_id not in existing_organization_ids:
return None
return {"organization_id": organization_id}
find_unique = AsyncMock(side_effect=_find_unique)
fake_prisma = MagicMock()
fake_prisma.db.litellm_organizationtable.find_unique = find_unique
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_organization_ids": existing_organization_ids,
"find_unique": find_unique,
}
def test_update_default_team_settings_rejects_unknown_organization(
mock_proxy_config, mock_auth, mock_organization_lookup
):
"""Regression: an unknown default org saved fine here and then failed every
future team creation, far from the admin who typed it."""
mock_organization_lookup["existing_organization_ids"].add("real-org")
resp = client.patch(
"/update/default_team_settings",
json={"max_budget": 10.0, "organization_id": "ghost-org"},
)
assert resp.status_code == 400, resp.text
assert "ghost-org" 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_when_organization_exists(
mock_proxy_config, mock_auth, mock_organization_lookup
):
"""A real organization id still saves and reaches the in-memory settings."""
mock_organization_lookup["existing_organization_ids"].add("real-org")
resp = client.patch(
"/update/default_team_settings",
json={"max_budget": 10.0, "organization_id": "real-org"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["settings"]["organization_id"] == "real-org"
assert mock_proxy_config["save_call_count"]() == 1
import litellm
assert litellm.default_team_params["organization_id"] == "real-org"
def test_update_default_team_settings_without_organization_skips_lookup(
mock_proxy_config, mock_auth, mock_organization_lookup
):
"""Settings changes that don't set an organization must not pay for a DB round trip."""
resp = client.patch(
"/update/default_team_settings",
json={"max_budget": 10.0},
)
assert resp.status_code == 200, resp.text
mock_organization_lookup["find_unique"].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

View file

@ -1,8 +1,8 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import { renderWithProviders, testQueryClient } from "../../tests/test-utils";
import TeamSSOSettings from "./TeamSSOSettings";
import * as networking from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
@ -37,6 +37,46 @@ vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: vi.fn((model: string) => model),
}));
vi.mock("./common_components/OrganizationDropdown", () => ({
default: ({
organizations,
value,
onChange,
placeholder,
loading,
}: {
organizations?: { organization_id: string; organization_alias: string }[] | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
loading?: boolean;
}) => (
<div>
<select
data-testid="organization-dropdown"
data-loading={String(Boolean(loading))}
aria-label="Default organization"
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
>
<option value="">{placeholder}</option>
{organizations?.map((org) => (
<option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</option>
))}
</select>
<button
type="button"
data-testid="organization-dropdown-clear"
onClick={() => onChange?.(undefined as unknown as string)}
>
Clear organization
</button>
</div>
),
}));
vi.mock("./ModelSelect/ModelSelect", () => {
const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (
<select
@ -145,8 +185,14 @@ vi.mock("antd", async (importOriginal) => {
const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings);
const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings);
const mockOrganizationListCall = vi.mocked(networking.organizationListCall);
const mockNotificationsManager = vi.mocked(NotificationsManager);
const MOCK_ORGANIZATIONS = [
{ organization_id: "org-1", organization_alias: "Engineering" },
{ organization_id: "org-2", organization_alias: "Sales" },
];
describe("TeamSSOSettings", () => {
const defaultProps = {
accessToken: "test-token",
@ -165,8 +211,12 @@ describe("TeamSSOSettings", () => {
},
};
const getOrganizationRow = () => screen.getByText("Default Organization").closest(".ant-row") as HTMLElement;
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
mockOrganizationListCall.mockResolvedValue(MOCK_ORGANIZATIONS);
});
// --- Loading & Error States ---
@ -436,6 +486,124 @@ describe("TeamSSOSettings", () => {
});
});
// --- Default Organization ---
it("should display the default organization alias and id in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: { ...mockSettingsResponse.values, organization_id: "org-2" },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(within(getOrganizationRow()).getByText("Sales (org-2)")).toBeInTheDocument();
});
expect(
screen.getByText("Teams created without an explicit organization are assigned to this organization."),
).toBeInTheDocument();
});
it("should fall back to the raw organization id when it is not in the organization list", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: { ...mockSettingsResponse.values, organization_id: "org-deleted" },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(within(getOrganizationRow()).getByText("org-deleted")).toBeInTheDocument();
});
});
it("should display 'Not set' when the settings payload has no organization_id", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(within(getOrganizationRow()).getByText("Not set")).toBeInTheDocument();
});
});
it("should populate the organization dropdown with the fetched organizations in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
const dropdown = screen.getByTestId("organization-dropdown");
expect(within(dropdown).getByRole("option", { name: "Engineering (org-1)" })).toBeInTheDocument();
expect(within(dropdown).getByRole("option", { name: "Sales (org-2)" })).toBeInTheDocument();
});
});
it("should send the selected organization_id when saving", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: { ...mockSettingsResponse.values, organization_id: "org-2" },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
expect(screen.getByRole("option", { name: "Sales (org-2)" })).toBeInTheDocument();
});
await userEvent.selectOptions(screen.getByTestId("organization-dropdown"), "org-2");
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", {
...mockSettingsResponse.values,
organization_id: "org-2",
});
});
await waitFor(() => {
expect(within(getOrganizationRow()).getByText("Sales (org-2)")).toBeInTheDocument();
});
});
it("should send a null organization_id when the selection is cleared", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: { ...mockSettingsResponse.values, organization_id: "org-2" },
});
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: { ...mockSettingsResponse.values, organization_id: null },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByTestId("organization-dropdown-clear"));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", {
...mockSettingsResponse.values,
organization_id: null,
});
});
await waitFor(() => {
expect(within(getOrganizationRow()).getByText("Not set")).toBeInTheDocument();
});
});
it("should show error notification when save fails", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));

View file

@ -1,11 +1,13 @@
import React, { useState, useEffect } from "react";
import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd";
import { EditOutlined, SaveOutlined } from "@ant-design/icons";
import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking";
import { getDefaultTeamSettings, updateDefaultTeamSettings, Organization } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import NotificationsManager from "./molecules/notifications_manager";
import { ModelSelect } from "./ModelSelect/ModelSelect";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
const { Title, Text } = Typography;
@ -67,6 +69,11 @@ const renderTags = (values: string[], displayFn?: (v: string) => string) => {
);
};
const getOrganizationLabel = (organizationId: string, organizations: Organization[] | undefined): string => {
const organization = organizations?.find((org) => org.organization_id === organizationId);
return organization?.organization_alias ? `${organization.organization_alias} (${organizationId})` : organizationId;
};
interface SettingsValues {
max_budget: number | null;
budget_duration: string | null;
@ -74,6 +81,7 @@ interface SettingsValues {
rpm_limit: number | null;
models: string[];
team_member_permissions: string[];
organization_id: string | null;
}
const DEFAULT_VALUES: SettingsValues = {
@ -83,6 +91,7 @@ const DEFAULT_VALUES: SettingsValues = {
rpm_limit: null,
models: [],
team_member_permissions: [],
organization_id: null,
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
@ -92,6 +101,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const [editedValues, setEditedValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [saving, setSaving] = useState<boolean>(false);
const [fetchError, setFetchError] = useState<boolean>(false);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
useEffect(() => {
const fetchSettings = async () => {
@ -272,6 +282,29 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
<div className="mb-8">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Access & Permissions</div>
<div className="border-t border-gray-100">
<SettingRow
label="Default Organization"
description="Teams created without an explicit organization are assigned to this organization."
isEditing={isEditing}
viewContent={
values.organization_id ? (
<Text>{getOrganizationLabel(values.organization_id, organizations)}</Text>
) : (
<NotSet />
)
}
editContent={
<OrganizationDropdown
organizations={organizations}
loading={isOrganizationsLoading}
value={editedValues.organization_id ?? undefined}
onChange={(organizationId) => update("organization_id", organizationId || null)}
placeholder="Select an organization"
style={{ maxWidth: 320 }}
/>
}
/>
<SettingRow
label="Models"
description="Default list of models that new teams can access."

View file

@ -11,6 +11,7 @@ interface OrganizationDropdownProps {
disabled?: boolean;
loading?: boolean;
style?: React.CSSProperties;
placeholder?: string;
}
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
@ -20,11 +21,12 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
disabled,
loading,
style,
placeholder = "All Organizations",
}) => {
return (
<Select
showSearch
placeholder="All Organizations"
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}

View file

@ -23610,6 +23610,11 @@ export interface components {
* @default []
*/
models: string[];
/**
* Organization Id
* @description Default organization for new teams created without an explicit organization
*/
organization_id?: string | null;
/**
* Rpm Limit
* @description Default rpm limit for new automatically created teams