mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
feat(team): let default_team_params attach access_group_ids to every new team (#49908)
* feat(team): let default_team_params attach access_group_ids to every new team * fix(types): keep default access_group_ids as Sequence for the type discipline gate * fix(settings): chunk the default access group existence lookup * test(team): prove default access groups reach the saved team row and type the settings fixtures * fix(team): check default access groups on the writer so a fresh group is not rejected behind a read replica
This commit is contained in:
parent
18b02380e5
commit
4f06ca217f
10 changed files with 287 additions and 5 deletions
|
|
@ -1827,6 +1827,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
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ from litellm.proxy.spend_tracking.ptu_feature_flag import (
|
|||
is_ptu_cost_attribution_enabled,
|
||||
)
|
||||
from litellm.proxy.utils import CONFIG_PARAMS_TARGET, invalidate_config_param
|
||||
from litellm.repositories.chunked_in import find_many_in
|
||||
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,
|
||||
)
|
||||
|
|
@ -1172,6 +1174,33 @@ async def update_internal_user_settings(
|
|||
)
|
||||
|
||||
|
||||
async def _validate_default_access_groups_exist(access_group_ids: Sequence[str]) -> None:
|
||||
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 find_many_in(
|
||||
AccessGroupRepository(prisma_client, use_writer=True).table, "access_group_id", 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"],
|
||||
|
|
@ -1188,6 +1217,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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
|
@ -241,3 +242,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="Default organization for new teams created without an explicit organization",
|
||||
)
|
||||
access_group_ids: Sequence[str] | None = Field(
|
||||
default=None,
|
||||
description="Default access groups attached to new teams created without explicit access_group_ids",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Tests for applying default team params during team creation
|
|||
and loading default_team_params from DB on startup.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -11,6 +12,7 @@ from fastapi import HTTPException
|
|||
|
||||
import litellm
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_BudgetTable,
|
||||
LiteLLM_OrganizationTable,
|
||||
NewTeamRequest,
|
||||
|
|
@ -214,6 +216,59 @@ class TestNewTeamDefaultParamsApplied:
|
|||
assert data.rpm_limit == 500
|
||||
assert data.team_member_permissions == ["/key/generate", "/key/update"]
|
||||
|
||||
async def _create_team(
|
||||
self, monkeypatch: pytest.MonkeyPatch, data: NewTeamRequest
|
||||
) -> tuple[dict[str, object], AsyncMock, AsyncMock]:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.management_endpoints import team_endpoints
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
prisma_client: Final = proxy_server.prisma_client
|
||||
prisma_client.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
|
||||
async def _create(data: dict[str, object], include: object) -> LiteLLM_TeamTable:
|
||||
return LiteLLM_TeamTable(**data)
|
||||
|
||||
create: Final = AsyncMock(side_effect=_create)
|
||||
tx: Final = MagicMock()
|
||||
tx.litellm_teamtable.create = create
|
||||
prisma_client.db.tx.return_value.__aenter__.return_value = tx
|
||||
reconcile: Final = AsyncMock(return_value=())
|
||||
monkeypatch.setattr(team_endpoints, "reconcile_team_access_group_membership", reconcile)
|
||||
monkeypatch.setattr(team_endpoints, "invalidate_access_group_caches", AsyncMock())
|
||||
monkeypatch.setattr(team_endpoints, "_add_team_members_to_team", AsyncMock())
|
||||
|
||||
response: Final = await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock())
|
||||
return response, create, reconcile
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_access_group_ids_attach_to_a_team_created_without_any(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
|
||||
|
||||
response, create, reconcile = await self._create_team(monkeypatch, NewTeamRequest(team_alias="my-team"))
|
||||
|
||||
assert create.await_args is not None
|
||||
assert create.await_args.kwargs["data"]["access_group_ids"] == ["central-models"]
|
||||
assert response["access_group_ids"] == ["central-models"]
|
||||
reconcile.assert_awaited_once()
|
||||
assert reconcile.await_args is not None
|
||||
assert reconcile.await_args.args[1] == response["team_id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("explicit_access_group_ids", [["research"], []])
|
||||
async def test_explicit_access_group_ids_win_over_the_default(
|
||||
self, monkeypatch: pytest.MonkeyPatch, explicit_access_group_ids: list[str]
|
||||
):
|
||||
monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
|
||||
|
||||
response, create, _ = await self._create_team(
|
||||
monkeypatch, NewTeamRequest(team_alias="my-team", access_group_ids=explicit_access_group_ids)
|
||||
)
|
||||
|
||||
assert create.await_args is not None
|
||||
assert create.await_args.kwargs["data"]["access_group_ids"] == explicit_access_group_ids
|
||||
assert response["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."""
|
||||
|
|
|
|||
|
|
@ -784,10 +784,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):
|
||||
|
|
@ -832,6 +837,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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Final
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -3498,6 +3498,89 @@ 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: pytest.MonkeyPatch) -> Callable[[frozenset[str]], AsyncMock]:
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server as proxy_server_module
|
||||
|
||||
def _install(existing_access_group_ids: frozenset[str]) -> AsyncMock:
|
||||
async def _find_many(where: dict[str, dict[str, list[str]]]) -> list[MagicMock]:
|
||||
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: Final = AsyncMock(side_effect=_find_many)
|
||||
fake_prisma: Final = MagicMock()
|
||||
fake_prisma.writer_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 find_many
|
||||
|
||||
return _install
|
||||
|
||||
|
||||
def test_update_default_team_settings_rejects_unknown_access_group(
|
||||
mock_proxy_config: dict[str, object],
|
||||
mock_auth: None,
|
||||
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
mock_access_group_lookup(frozenset({"real-group"}))
|
||||
|
||||
resp: Final = 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
|
||||
assert litellm.default_team_params == {}
|
||||
|
||||
|
||||
def test_update_default_team_settings_saves_existing_access_groups(
|
||||
mock_proxy_config: dict[str, object],
|
||||
mock_auth: None,
|
||||
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
|
||||
) -> None:
|
||||
import litellm
|
||||
|
||||
mock_access_group_lookup(frozenset({"real-group", "other-group"}))
|
||||
|
||||
resp: Final = 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
|
||||
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: dict[str, object],
|
||||
mock_auth: None,
|
||||
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
|
||||
) -> None:
|
||||
find_many: Final = mock_access_group_lookup(frozenset())
|
||||
|
||||
resp: Final = client.patch(
|
||||
"/update/default_team_settings",
|
||||
json={"access_group_ids": []},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Edit, Save } from "lucide-react";
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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";
|
||||
|
|
@ -26,6 +27,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 {
|
||||
|
|
@ -102,6 +104,7 @@ interface SettingsValues {
|
|||
models: string[];
|
||||
team_member_permissions: string[];
|
||||
organization_id: string | null;
|
||||
access_group_ids: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: SettingsValues = {
|
||||
|
|
@ -112,6 +115,7 @@ const DEFAULT_VALUES: SettingsValues = {
|
|||
models: [],
|
||||
team_member_permissions: [],
|
||||
organization_id: null,
|
||||
access_group_ids: [],
|
||||
};
|
||||
|
||||
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
||||
|
|
@ -124,6 +128,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 () => {
|
||||
|
|
@ -348,6 +355,22 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
|||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("teams.ssoSettings.fields.accessGroups.label")}
|
||||
description={t("teams.ssoSettings.fields.accessGroups.description")}
|
||||
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={t("teams.ssoSettings.fields.accessGroups.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("teams.ssoSettings.fields.models.label")}
|
||||
description={t("teams.ssoSettings.fields.models.description")}
|
||||
|
|
|
|||
|
|
@ -16755,9 +16755,14 @@
|
|||
"description": "Teams created without an explicit organization are assigned to this organization.",
|
||||
"placeholder": "Select an organization"
|
||||
},
|
||||
"accessGroups": {
|
||||
"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.",
|
||||
"placeholder": "Select access groups"
|
||||
},
|
||||
"models": {
|
||||
"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."
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Team Member Permissions",
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -32655,6 +32655,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