mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(team): let default_team_params attach access_group_ids to every new team
This commit is contained in:
parent
add7dea9aa
commit
929b8097a7
9 changed files with 260 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue