From ab44e8d60222726fcab4562422bda9161ddf002a Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:44:09 -0700 Subject: [PATCH] fix(team_endpoints): stop partial /team/update from wiping team metadata (#36328) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 21 +++++ .../test_team_endpoints.py | 81 +++++++++++++++++-- .../src/components/team/TeamInfo.test.tsx | 29 +++++++ .../src/components/team/TeamInfo.tsx | 2 +- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 72cdc75c29e..90d7539b38d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -10,6 +10,7 @@ All /team management endpoints """ import asyncio +import copy import json import math import traceback @@ -2189,6 +2190,26 @@ async def update_team( if field in updated_kv } + _writes_metadata_backed_field: Final = any( + field in updated_kv + for field in ( + *LiteLLM_ManagementEndpoint_MetadataFields, + *LiteLLM_ManagementEndpoint_MetadataFields_Premium, + ) + ) + if isinstance(existing_team_row.metadata, dict): + if "metadata" not in updated_kv and (_team_member_fields_in_request or _writes_metadata_backed_field): + updated_kv["metadata"] = copy.deepcopy(existing_team_row.metadata) + elif isinstance(updated_kv.get("metadata"), dict): + updated_kv["metadata"] = { + **updated_kv["metadata"], + **{ + key: existing_team_row.metadata[key] + for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS + if key in existing_team_row.metadata + }, + } + if _team_member_fields_in_request and TeamMemberBudgetHandler.should_create_budget( team_member_budget=data.team_member_budget, team_member_rpm_limit=data.team_member_rpm_limit, diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 166fcb2863c..019ebc9807c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2910,6 +2910,7 @@ async def test_update_team_with_team_member_budget_duration( "metadata": {"team_member_budget_id": "budget_123"}, } mock_existing_team.metadata = {"team_member_budget_id": "budget_123"} + mock_existing_team.members_with_roles = [] mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) @@ -11290,6 +11291,78 @@ async def test_patch_preserves_required_metadata_key_that_post_would_wipe(): assert patch_meta == {"cost_center": "FINOPS-1", "team_notes": "edited"} # preserved by PATCH +_STORED_METADATA_WITH_BUDGET: Final = { + "team_member_budget_id": "budget-existing-123", + "team_member_key_duration": "30d", + "logging": [{"callback_name": "langfuse", "callback_type": "success"}], + "cost_center": "cc-1234", +} + + +async def _written_metadata_with_budget(kind, body): + """Like ``_written_metadata`` but the team already owns a member budget row.""" + from litellm.proxy._types import LiteLLM_BudgetTable + + with ( + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: proxy_server module global is the endpoint's only injection point + patch( # test-quality-ok: update_team imports update_budget at call time; the module attribute is its only seam + "litellm.proxy.management_endpoints.budget_management_endpoints.update_budget", + AsyncMock(return_value=LiteLLM_BudgetTable(budget_id="budget-existing-123")), + ), + ): + return await _written_metadata(kind, dict(_STORED_METADATA_WITH_BUDGET), body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +@pytest.mark.parametrize( + "body", + [ + {"team_member_budget": 50.0}, + {"team_member_budget_duration": "1d"}, + {"team_member_tpm_limit": 500}, + {"team_member_rpm_limit": 5}, + ], + ids=lambda body: next(iter(body)), +) +async def test_team_member_budget_only_update_preserves_stored_metadata(kind, body): + """LIT-5150: a budget-only update that omits ``metadata`` must not replace the + stored metadata JSON with just ``{"team_member_budget_id": ...}``.""" + assert await _written_metadata_with_budget(kind, body) == _STORED_METADATA_WITH_BUDGET + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +async def test_team_member_key_duration_only_update_preserves_stored_metadata(kind): + """LIT-5150: a metadata-backed field sent alone is merged into the stored + metadata instead of becoming the whole metadata JSON.""" + written = await _written_metadata_with_budget(kind, {"team_member_key_duration": "7d"}) + + assert written == {**_STORED_METADATA_WITH_BUDGET, "team_member_key_duration": "7d"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +async def test_explicit_null_metadata_with_budget_field_still_clears_metadata(kind): + """``metadata: null`` is an explicit clear, so only the server-owned budget link survives.""" + written = await _written_metadata_with_budget(kind, {"metadata": None, "team_member_budget": 7.0}) + + assert written == {"team_member_budget_id": "budget-existing-123"} + + +@pytest.mark.asyncio +async def test_metadata_only_update_keeps_team_member_budget_link(): + """LIT-5150: rewriting metadata without any team member field must not drop the + server-owned ``team_member_budget_id``, or the member budget silently resets.""" + body = {"metadata": {"cost_center": "cc-9999"}} + + post_meta = await _written_metadata_with_budget("post", body) + patch_meta = await _written_metadata_with_budget("patch", body) + + assert post_meta == {"cost_center": "cc-9999", "team_member_budget_id": "budget-existing-123"} + assert patch_meta == {**_STORED_METADATA_WITH_BUDGET, "cost_center": "cc-9999"} + + @pytest.mark.asyncio @pytest.mark.parametrize( "body, field, expected", @@ -11318,17 +11391,15 @@ async def test_top_level_fields_identical_post_and_patch(body, field, expected): @pytest.mark.asyncio async def test_patch_strips_system_managed_metadata_key_like_post(): """A caller cannot inject/overwrite server-owned keys via PATCH any more than - via POST: team_member_budget_id is stripped from the write in both.""" + via POST: the stored team_member_budget_id wins over the caller's value in both.""" existing = {"team_member_budget_id": "budget-123", "cost_center": "1234"} body = {"metadata": {"team_member_budget_id": "HACKED", "cost_center": "9999"}} post_meta = await _written_metadata("post", existing, body) patch_meta = await _written_metadata("patch", existing, body) - assert "team_member_budget_id" not in post_meta - assert "team_member_budget_id" not in patch_meta - assert post_meta == {"cost_center": "9999"} - assert patch_meta == {"cost_center": "9999"} + assert post_meta == {"cost_center": "9999", "team_member_budget_id": "budget-123"} + assert patch_meta == {"cost_center": "9999", "team_member_budget_id": "budget-123"} @pytest.mark.parametrize( diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index aedc04283f5..a993c1f6bd6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -1538,6 +1538,35 @@ describe("TeamInfoView", () => { }); }); + describe("team member settings", () => { + it("should populate Default Key Duration from the team's stored metadata", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ metadata: { team_member_key_duration: "30d" } }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0); + }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + await user.click(await screen.findByRole("button", { name: /team member settings/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/^Default Key Duration/)).toHaveValue("30d"); + }); + }); + }); + describe("guardrails dropdown grouping", () => { const guardrail = (name: string, defaultOn: boolean) => ({ guardrail_name: name, diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index c2b8cd3cc56..23ecd19fdd7 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -335,7 +335,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]): default_team_member_models: info.default_team_member_models || [], team_member_budget: info.team_member_budget_table?.max_budget, team_member_budget_duration: info.team_member_budget_table?.budget_duration, - team_member_key_duration: info.team_member_key_duration, + team_member_key_duration: info.metadata?.team_member_key_duration, team_member_tpm_limit: info.team_member_budget_table?.tpm_limit, team_member_rpm_limit: info.team_member_budget_table?.rpm_limit, budget_duration: info.budget_duration,