feat(team): let default_team_params attach access_group_ids to every new team

This commit is contained in:
jesus 2026-11-03 18:12:47 +00:00
parent add7dea9aa
commit 929b8097a7
9 changed files with 260 additions and 3 deletions

View file

@ -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

View file

@ -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,

View file

@ -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",
)

View file

@ -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."""

View file

@ -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

View file

@ -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

View file

@ -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 }) => (
<select
data-testid="access-group-selector"
multiple
value={value}
onChange={(e) => onChange?.(Array.from(e.target.selectedOptions, (option) => option.value))}
aria-label="Access Groups"
>
<option value="ag-1">Central Models</option>
<option value="ag-2">Research</option>
</select>
),
}));
vi.mock("./ModelSelect/ModelSelect", () => {
const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (
<select
@ -134,6 +160,7 @@ describe("TeamSSOSettings", () => {
rpm_limit: 100,
models: ["gpt-4"],
team_member_permissions: ["/key/generate", "/key/update"],
access_group_ids: ["ag-1"],
},
};
@ -247,6 +274,47 @@ describe("TeamSSOSettings", () => {
});
});
it("should display default access groups by name in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: { ...mockSettingsResponse.values, access_group_ids: ["ag-1", "ag-missing"] },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(await screen.findByText("Central Models")).toBeInTheDocument();
expect(screen.getByText("ag-missing")).toBeInTheDocument();
});
it("should say the models default only applies to SSO auto-created teams", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(await screen.findByText(/auto-created from SSO groups/i)).toBeInTheDocument();
});
it("should send the newly selected access group alongside the existing one when saving", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: { ...mockSettingsResponse.values, access_group_ids: ["ag-1", "ag-2"] },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await userEvent.click(await screen.findByRole("button", { name: /Edit Settings/i }));
await userEvent.selectOptions(screen.getByTestId("access-group-selector"), "ag-2");
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith(
"test-token",
expect.objectContaining({ access_group_ids: ["ag-1", "ag-2"] }),
);
});
expect(await screen.findByText("Research")).toBeInTheDocument();
});
it("should display permissions as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);

View file

@ -1,6 +1,7 @@
import { Edit, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -25,6 +26,7 @@ import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_compone
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import { toast } from "@/lib/toast";
import { ModelSelect } from "./ModelSelect/ModelSelect";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
interface TeamSSOSettingsProps {
@ -98,6 +100,7 @@ interface SettingsValues {
models: string[];
team_member_permissions: string[];
organization_id: string | null;
access_group_ids: string[];
}
const DEFAULT_VALUES: SettingsValues = {
@ -108,6 +111,7 @@ const DEFAULT_VALUES: SettingsValues = {
models: [],
team_member_permissions: [],
organization_id: null,
access_group_ids: [],
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
@ -119,6 +123,9 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const [saving, setSaving] = useState<boolean>(false);
const [fetchError, setFetchError] = useState<boolean>(false);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
const { data: accessGroups } = useAccessGroups();
const getAccessGroupLabel = (accessGroupId: string): string =>
accessGroups?.find((group) => group.access_group_id === accessGroupId)?.access_group_name ?? accessGroupId;
useEffect(() => {
const fetchSettings = async () => {
@ -345,9 +352,25 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
}
/>
<SettingRow
label="Access Groups"
description="Access groups attached to teams created without explicit access groups. Use this to manage model access for all new teams in one place."
isEditing={isEditing}
viewContent={renderTags(values.access_group_ids, getAccessGroupLabel)}
editContent={
<div className="*:w-full">
<AccessGroupSelector
value={editedValues.access_group_ids || []}
onChange={(v) => update("access_group_ids", v)}
placeholder="Select access groups"
/>
</div>
}
/>
<SettingRow
label="Models"
description="Default list of models that new teams can access."
description="Default list of models for teams auto-created from SSO groups. Teams created from the UI or /team/new are not affected."
isEditing={isEditing}
viewContent={renderTags(values.models, getModelDisplayName)}
editContent={

View file

@ -32119,6 +32119,11 @@ export interface components {
* `models` is the exception: it only applies to teams automatically created by LiteLLM via SSO Groups.
*/
DefaultTeamSSOParams: {
/**
* Access Group Ids
* @description Default access groups attached to new teams created without explicit access_group_ids
*/
access_group_ids?: string[] | null;
/**
* Budget Duration
* @description Default budget duration for new teams, when not explicitly provided (e.g. '24h', '7d', '30d')