feat(team): add PATCH /team/{team_id} with JSON merge patch semantics

Add a RESTful PATCH /team/{team_id} that partially updates a team using RFC 7386 JSON Merge Patch. team_id comes from the path, and metadata is merged with the team's stored metadata instead of being replaced wholesale the way POST /team/update does: an omitted key is preserved, key: null deletes it, and any other value overwrites, recursing into nested objects. Every other field behaves the same as POST /team/update

The handler delegates to the existing update path, so authorization, budget checks, system-managed-key stripping, metadata encryption, cache refresh, and audit logging are shared rather than reimplemented. POST /team/update is untouched, so the change is purely additive
This commit is contained in:
Yuneng Jiang 2026-07-10 18:46:13 -07:00
parent 14338a2471
commit e9246e924e
No known key found for this signature in database
5 changed files with 570 additions and 1 deletions

View file

@ -0,0 +1,21 @@
"""RFC 7386 JSON Merge Patch (https://www.rfc-editor.org/rfc/rfc7386)."""
from pydantic import JsonValue
def apply_json_merge_patch(target: JsonValue, patch: JsonValue) -> JsonValue:
"""Apply an RFC 7386 JSON Merge Patch to ``target`` and return the result.
- a key absent from ``patch`` keeps its value in ``target``
- a key mapped to ``null`` in ``patch`` is removed from the result
- any other value overwrites, recursing into nested objects
``target`` is never mutated; a new value is returned.
"""
if not isinstance(patch, dict):
return patch
base = target if isinstance(target, dict) else {}
preserved = {key: value for key, value in base.items() if key not in patch}
applied = {key: apply_json_merge_patch(base.get(key), value) for key, value in patch.items() if value is not None}
return {**preserved, **applied}

View file

@ -14,7 +14,7 @@ import json
import math
import traceback
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from typing import Annotated, Any, Dict, List, Optional, Tuple, Union, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -76,6 +76,7 @@ from litellm.proxy.auth.auth_checks import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
_is_user_org_admin_for_team,
@ -1936,6 +1937,87 @@ async def update_team(
raise handle_exception_on_proxy(e)
@router.patch(
"/team/{team_id}",
tags=["team management"],
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_TeamTable,
)
async def patch_team(
team_id: str,
http_request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
litellm_changed_by: Annotated[
Optional[str],
Header(
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
] = None,
):
"""
Partially update a team using RFC 7386 JSON Merge Patch semantics.
`team_id` is taken from the path. `metadata` is merged with the team's stored
metadata rather than replacing it: an omitted key is preserved, `key: null`
deletes it, and any other value overwrites (recursing into nested objects).
Every other field behaves exactly like `POST /team/update` (omitted preserves,
a value overwrites). Returns the full updated team.
```
curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data-raw '{
"metadata": {"cost_center": "1234", "deprecated_key": null}
}'
```
"""
from litellm.proxy.proxy_server import prisma_client
try:
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
try:
body = await http_request.json()
except (json.JSONDecodeError, ValueError):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
body_team_id = body.pop("team_id", None)
if body_team_id is not None and body_team_id != team_id:
raise HTTPException(
status_code=400,
detail={"error": f"team_id in body ({body_team_id}) does not match team_id in path ({team_id})"},
)
if "metadata" in body:
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
if existing_team_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Team not found, passed team_id={team_id}"},
)
existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {}
body["metadata"] = apply_json_merge_patch(existing_metadata, body["metadata"])
update_request = UpdateTeamRequest(team_id=team_id, **body)
result = await update_team(
data=update_request,
http_request=http_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
return result["data"]
except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract
raise handle_exception_on_proxy(e)
def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None:
"""Set budget_reset_at in updated_kv if budget_duration is provided."""
if data.budget_duration is not None:

View file

@ -0,0 +1,71 @@
import copy
import pytest
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
# RFC 7386 Appendix A — the normative test suite for JSON Merge Patch.
# https://www.rfc-editor.org/rfc/rfc7386#appendix-A
RFC_7386_APPENDIX_A = [
({"a": "b"}, {"a": "c"}, {"a": "c"}),
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}),
({"a": "b"}, {"a": None}, {}),
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}),
({"a": ["b"]}, {"a": "c"}, {"a": "c"}),
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}),
({"a": {"b": "c"}}, {"a": {"b": "d", "c": None}}, {"a": {"b": "d"}}),
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}),
(["a", "b"], ["c", "d"], ["c", "d"]),
({"a": "b"}, ["c"], ["c"]),
({"a": "foo"}, None, None),
({"a": "foo"}, "bar", "bar"),
({"e": None}, {"a": 1}, {"e": None, "a": 1}),
([1, 2], {"a": "b", "c": None}, {"a": "b"}),
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}),
]
@pytest.mark.parametrize("target, patch, expected", RFC_7386_APPENDIX_A)
def test_rfc_7386_appendix_a(target, patch, expected):
assert apply_json_merge_patch(target, patch) == expected
def test_does_not_mutate_target():
"""The target must be treated as immutable — a fresh value is returned."""
target = {"keep": "me", "nested": {"a": 1, "b": 2}, "drop": "later"}
target_snapshot = copy.deepcopy(target)
result = apply_json_merge_patch(target, {"nested": {"b": None, "c": 3}, "drop": None})
assert target == target_snapshot, "apply_json_merge_patch mutated its target argument"
assert result == {"keep": "me", "nested": {"a": 1, "c": 3}}
assert result["nested"] is not target["nested"]
def test_absent_key_is_preserved_but_null_key_is_deleted():
"""The core distinction PATCH relies on: omission preserves, explicit null deletes."""
target = {"cost_center": "1234", "team": "core"}
# Omitting cost_center preserves it; only the explicitly-null key is removed.
assert apply_json_merge_patch(target, {"team": "platform"}) == {
"cost_center": "1234",
"team": "platform",
}
assert apply_json_merge_patch(target, {"cost_center": None}) == {"team": "core"}
def test_deep_nested_merge_and_delete():
target = {"limits": {"gpt-4": {"rpm": 100, "tpm": 1000}, "gpt-3.5": {"rpm": 200}}}
patch = {"limits": {"gpt-4": {"tpm": 2000}, "gpt-3.5": None, "claude": {"rpm": 50}}}
assert apply_json_merge_patch(target, patch) == {
"limits": {"gpt-4": {"rpm": 100, "tpm": 2000}, "claude": {"rpm": 50}}
}
def test_scalar_patch_replaces_object_wholesale():
assert apply_json_merge_patch({"a": {"b": 1}}, 5) == 5
def test_object_patch_over_non_object_target_starts_from_empty():
assert apply_json_merge_patch("not-an-object", {"a": 1, "b": None}) == {"a": 1}

View file

@ -9535,3 +9535,332 @@ async def test_new_team_rejects_reserved_ui_session_team_id():
assert exc_info.value.code == "400"
assert "reserved" in str(exc_info.value.message)
mock_prisma.get_data.assert_not_called()
# ---------------------------------------------------------------------------
# PATCH /team/{team_id} — RFC 7386 JSON Merge Patch
#
# The new PATCH endpoint delegates to the same write path as POST /team/update;
# the single intended divergence is metadata. POST replaces the metadata column
# wholesale, PATCH merges it per RFC 7386 (omit preserves, null deletes, value
# overwrites, recursing into nested objects). Every other field must behave
# identically. Each test drives BOTH endpoints against an identical mocked team
# and asserts on the exact dict handed to litellm_teamtable.update.
# ---------------------------------------------------------------------------
_PATCH_TEAM_ID = "team-merge-patch-test"
_ABSENT = object()
async def _drive_team_write(
kind,
*,
existing_metadata=None,
existing_kwargs=None,
payload=None,
raw_body=None,
user=None,
find_returns_none=False,
json_side_effect=None,
):
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
"""
from unittest.mock import AsyncMock, MagicMock, Mock
from unittest.mock import patch as _patch
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
UpdateTeamRequest,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import (
patch_team,
update_team,
)
existing = LiteLLM_TeamTable(
team_id=_PATCH_TEAM_ID,
team_alias="t",
metadata=existing_metadata,
organization_id=None,
**(existing_kwargs or {}),
)
auth = user or UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u")
with (
_patch("litellm.proxy.proxy_server.prisma_client") as pc,
_patch("litellm.proxy.proxy_server.llm_router", None),
_patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
_patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
_patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
_patch(
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
new=AsyncMock(),
),
):
pc.db.litellm_teamtable.find_unique = AsyncMock(
return_value=None if find_returns_none else existing
)
pc.db.litellm_teamtable.update = AsyncMock(
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
)
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
req = Mock(spec=Request)
if kind == "post":
result = await update_team(
data=UpdateTeamRequest(team_id=_PATCH_TEAM_ID, **(payload or {})),
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
else:
if json_side_effect is not None:
req.json = AsyncMock(side_effect=json_side_effect)
else:
req.json = AsyncMock(
return_value=raw_body if raw_body is not None else dict(payload or {})
)
result = await patch_team(
team_id=_PATCH_TEAM_ID,
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
return result, pc.db.litellm_teamtable.update
async def _written_metadata(kind, existing_metadata, body):
_, update_mock = await _drive_team_write(kind, existing_metadata=existing_metadata, payload=body)
written = update_mock.call_args.kwargs["data"]
return written["metadata"] if "metadata" in written else _ABSENT
# (label, existing_metadata, merge_patch_body, expected_POST_metadata, expected_PATCH_metadata)
_METADATA_MAPPING = [
(
"omit-metadata-preserves-in-both",
{"cost_center": "1234"},
{"tpm_limit": 5},
_ABSENT, # POST: metadata column left untouched
_ABSENT, # PATCH: metadata column left untouched
),
(
"add-key-POST-wipes-others-PATCH-preserves",
{"cost_center": "1234", "foo": "bar"},
{"metadata": {"foo": "baz"}},
{"foo": "baz"}, # POST replaces wholesale -> cost_center wiped
{"cost_center": "1234", "foo": "baz"}, # PATCH merges -> cost_center kept
),
(
"overwrite-plus-null-delete-plus-add",
{"cost_center": "1234", "foo": "bar"},
{"metadata": {"cost_center": "9999", "foo": None, "new": "x"}},
{"cost_center": "9999", "foo": None, "new": "x"}, # POST stores the literal null
{"cost_center": "9999", "new": "x"}, # PATCH deletes foo via null
),
(
"null-delete-one-key",
{"a": 1, "b": 2},
{"metadata": {"b": None}},
{"b": None}, # POST wholesale replace -> only b:null survives
{"a": 1}, # PATCH deletes b, preserves a
),
(
"nested-object-deep-merge",
{"settings": {"x": 1, "y": 2}},
{"metadata": {"settings": {"y": 3, "z": 4}}},
{"settings": {"y": 3, "z": 4}}, # POST replaces the nested object wholesale
{"settings": {"x": 1, "y": 3, "z": 4}}, # PATCH deep-merges the nested object
),
(
"nested-object-null-delete",
{"settings": {"x": 1, "y": 2}},
{"metadata": {"settings": {"x": None}}},
{"settings": {"x": None}}, # POST wholesale
{"settings": {"y": 2}}, # PATCH deletes nested key, keeps sibling
),
(
"empty-object-POST-clears-PATCH-noops",
{"a": 1},
{"metadata": {}},
{}, # POST replaces with an empty object
{"a": 1}, # PATCH: an empty patch is a no-op
),
(
"metadata-null-clears-in-both",
{"a": 1},
{"metadata": None},
None, # POST clears the column
None, # PATCH: an RFC 7386 null patch clears the column too (parity)
),
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"label, existing_metadata, body, expected_post, expected_patch",
_METADATA_MAPPING,
ids=[row[0] for row in _METADATA_MAPPING],
)
async def test_post_vs_patch_metadata_write_mapping(
label, existing_metadata, body, expected_post, expected_patch
):
"""Exhaustive map: POST replaces metadata wholesale, PATCH merges per RFC 7386."""
post_meta = await _written_metadata("post", existing_metadata, body)
patch_meta = await _written_metadata("patch", existing_metadata, body)
assert post_meta == expected_post, f"POST metadata mismatch for '{label}'"
assert patch_meta == expected_patch, f"PATCH metadata mismatch for '{label}'"
@pytest.mark.asyncio
async def test_patch_preserves_required_metadata_key_that_post_would_wipe():
"""The reason PATCH exists: editing one metadata key must not silently drop
the others, which POST /team/update does because it replaces wholesale."""
existing = {"cost_center": "FINOPS-1", "team_notes": "keep me"}
body = {"metadata": {"team_notes": "edited"}}
post_meta = await _written_metadata("post", existing, body)
patch_meta = await _written_metadata("patch", existing, body)
assert post_meta == {"team_notes": "edited"}
assert "cost_center" not in post_meta # wiped by POST
assert patch_meta == {"cost_center": "FINOPS-1", "team_notes": "edited"} # preserved by PATCH
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body, field, expected",
[
({"tpm_limit": 50}, "tpm_limit", 50),
({"tpm_limit": None}, "tpm_limit", None),
({"models": ["gpt-4", "claude-3"]}, "models", ["gpt-4", "claude-3"]),
({"blocked": True}, "blocked", True),
({"max_budget": 10.0}, "max_budget", 10.0),
],
)
async def test_top_level_fields_identical_post_and_patch(body, field, expected):
"""Non-metadata fields are unaffected by merge semantics: value overwrites in both,
and neither touches metadata when the patch omits it."""
_, post_update = await _drive_team_write("post", existing_metadata={"k": "v"}, payload=body)
_, patch_update = await _drive_team_write("patch", existing_metadata={"k": "v"}, payload=body)
post_written = post_update.call_args.kwargs["data"]
patch_written = patch_update.call_args.kwargs["data"]
assert post_written[field] == expected
assert patch_written[field] == expected
assert "metadata" not in post_written
assert "metadata" not in patch_written
@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."""
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"}
@pytest.mark.asyncio
@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True])
async def test_patch_rejects_non_object_body(raw_body):
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_rejects_invalid_json_body():
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body")
)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_rejects_team_id_mismatch_between_path_and_body():
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch",
existing_metadata={"a": 1},
raw_body={"team_id": "some-other-team", "tpm_limit": 5},
)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_accepts_matching_team_id_in_body():
"""A body team_id equal to the path is tolerated and does not leak into the write."""
_, update_mock = await _drive_team_write(
"patch",
existing_metadata={"a": 1},
raw_body={"team_id": _PATCH_TEAM_ID, "tpm_limit": 7},
)
written = update_mock.call_args.kwargs["data"]
assert written["tpm_limit"] == 7
@pytest.mark.asyncio
async def test_patch_team_not_found_returns_404():
from litellm.proxy._types import ProxyException
# metadata present -> patch_team does its own existence check
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", raw_body={"metadata": {"cost_center": "1"}}, find_returns_none=True
)
assert exc.value.code == "404" or exc.value.code == 404
# metadata absent -> existence check happens in the delegated update_team
with pytest.raises(ProxyException) as exc2:
await _drive_team_write("patch", raw_body={"tpm_limit": 5}, find_returns_none=True)
assert exc2.value.code == "404" or exc2.value.code == 404
@pytest.mark.asyncio
async def test_patch_enforces_team_access_via_delegation():
"""PATCH inherits POST's team-level RBAC: a caller who is neither proxy admin,
team admin, nor org admin of the team is rejected."""
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
outsider = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="outsider")
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", raw_body={"tpm_limit": 5}, user=outsider
)
assert exc.value.code == "403" or exc.value.code == 403
@pytest.mark.asyncio
async def test_patch_returns_full_team_object_not_wrapper():
"""Per REST convention the PATCH response is the full team, not POST's
{"team_id", "data"} envelope."""
from litellm.proxy._types import LiteLLM_TeamTable
result, _ = await _drive_team_write(
"patch", existing_metadata={"a": 1}, raw_body={"metadata": {"b": 2}}
)
assert isinstance(result, LiteLLM_TeamTable)
assert result.team_id == _PATCH_TEAM_ID

View file

@ -13842,6 +13842,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/team/{team_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch Team
* @description Partially update a team using RFC 7386 JSON Merge Patch semantics.
*
* `team_id` is taken from the path. `metadata` is merged with the team's stored
* metadata rather than replacing it: an omitted key is preserved, `key: null`
* deletes it, and any other value overwrites (recursing into nested objects).
* Every other field behaves exactly like `POST /team/update` (omitted preserves,
* a value overwrites). Returns the full updated team.
*
* ```
* curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{
* "metadata": {"cost_center": "1234", "deprecated_key": null}
* }'
* ```
*/
patch: operations["patch_team_team__team_id__patch"];
trace?: never;
};
"/team/{team_id}/callback": {
parameters: {
query?: never;
@ -50478,6 +50510,40 @@ export interface operations {
};
};
};
patch_team_team__team_id__patch: {
parameters: {
query?: never;
header?: {
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
"litellm-changed-by"?: string | null;
};
path: {
team_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LiteLLM_TeamTable"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_team_callbacks_team__team_id__callback_get: {
parameters: {
query?: never;