Merge pull request #32883 from BerriAI/litellm_/patch-endpoint-1d2646

feat(team): add RESTful PATCH /team/{team_id} with JSON merge patch semantics
This commit is contained in:
yuneng-jiang 2026-07-11 13:06:03 -07:00 committed by GitHub
commit 2e45fc5919
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 783 additions and 5 deletions

View file

@ -589,6 +589,7 @@ class LiteLLMRoutes(enum.Enum):
# team
"/team/new",
"/team/update",
"/team/{team_id}",
"/team/delete",
"/team/list",
"/v2/team/list",

View file

@ -102,7 +102,7 @@ from .auth_checks_organization import (
add_team_org_context_to_request_body,
organization_role_based_access_check,
)
from .auth_utils import get_model_from_request
from .auth_utils import get_model_from_request, get_request_route_template
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
@ -726,6 +726,7 @@ async def common_checks(
route=route,
request_body=request_body,
fetch_team_org_id=_fetch_team_org_id,
route_template=get_request_route_template(request),
)
_is_route_allowed = _is_api_route_allowed(

View file

@ -173,12 +173,17 @@ def _user_is_org_admin(
TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"})
# The RESTful update route carries the team id in the path. Match on the route
# template so the sibling /team/<verb> routes (which share the single-segment
# shape) are not mistaken for it and don't trigger a team lookup.
PATCH_TEAM_ROUTE_TEMPLATE = "/team/{team_id}"
async def add_team_org_context_to_request_body(
route: str,
request_body: dict,
fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]],
route_template: Optional[str] = None,
) -> dict:
"""
Return a copy of request_body with organization_id resolved from the target
@ -188,12 +193,20 @@ async def add_team_org_context_to_request_body(
the client having to send it. Returns request_body unchanged when it does
not apply, so callers that already pass organization_id and non-team routes
are untouched.
The team_id is taken from the body for TEAM_ORG_CONTEXT_ROUTES, or from the
last path segment when ``route_template`` is the ``/team/{team_id}`` route.
"""
if route not in TEAM_ORG_CONTEXT_ROUTES:
return request_body
if request_body.get("organization_id"):
return request_body
team_id = request_body.get("team_id")
if route in TEAM_ORG_CONTEXT_ROUTES:
team_id: Optional[str] = request_body.get("team_id")
elif route_template == PATCH_TEAM_ROUTE_TEMPLATE:
team_id = route.rsplit("/", 1)[-1]
else:
return request_body
if not isinstance(team_id, str) or not team_id:
return request_body
org_id = await fetch_team_org_id(team_id)

View file

@ -0,0 +1,33 @@
"""RFC 7386 JSON Merge Patch (https://www.rfc-editor.org/rfc/rfc7386)."""
from pydantic import JsonValue
# A merge patch recurses as deep as the client's JSON nests. Cap it far above any
# realistic team-metadata shape but well below Python's stack limit, so a
# pathologically deep patch is rejected instead of overflowing the stack.
_MAX_MERGE_DEPTH = 64
def apply_json_merge_patch(target: JsonValue, patch: JsonValue, _depth: int = 0) -> 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. Raises ``ValueError``
if ``patch`` nests deeper than ``_MAX_MERGE_DEPTH``.
"""
if not isinstance(patch, dict):
return patch
if _depth >= _MAX_MERGE_DEPTH:
raise ValueError(f"JSON merge patch nesting exceeds the maximum depth of {_MAX_MERGE_DEPTH}")
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, _depth + 1)
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

@ -53,6 +53,7 @@ IGNORE_FUNCTIONS = [
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
]

View file

@ -2724,6 +2724,164 @@ def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
)
# ── PATCH /team/{team_id}: same org-context + role reach as POST /team/update ──
@pytest.mark.asyncio
async def test_add_team_org_context_resolves_org_from_path_for_patch_route():
"""PATCH /team/{team_id} carries team_id in the PATH, not the body. The target
team's org is resolved from the last path segment (identified by the route
template) and injected, so an org admin of that team's org clears the same gate
they clear for POST /team/update."""
async def fetch(team_id: str):
assert team_id == "team-1"
return "org-1"
out = await add_team_org_context_to_request_body(
route="/team/team-1",
request_body={"metadata": {"cost_center": "x"}},
fetch_team_org_id=fetch,
route_template="/team/{team_id}",
)
assert out == {"metadata": {"cost_center": "x"}, "organization_id": "org-1"}
@pytest.mark.asyncio
async def test_add_team_org_context_path_noop_for_team_subresource():
"""A sub-resource like /team/{team_id}/members/me has a different route template,
so it is not mistaken for the bare team route and no org is injected."""
async def fetch(team_id: str):
raise AssertionError("must not resolve for a team sub-resource route")
body = {"foo": "bar"}
out = await add_team_org_context_to_request_body(
route="/team/team-1/members/me",
request_body=body,
fetch_team_org_id=fetch,
route_template="/team/{team_id}/members/me",
)
assert out == body
@pytest.mark.asyncio
async def test_add_team_org_context_noop_for_static_team_route():
"""A static sibling route (e.g. POST /team/new) whose resolved path also has the
single-segment shape has its own template, not /team/{team_id}, so no team lookup
is attempted the guard against a spurious DB hit on every /team/<verb> call."""
async def fetch(team_id: str):
raise AssertionError("must not resolve for a static /team/<verb> route")
body = {"team_alias": "new team"}
out = await add_team_org_context_to_request_body(
route="/team/new",
request_body=body,
fetch_team_org_id=fetch,
route_template="/team/new",
)
assert out == body
def test_patch_team_route_has_same_reach_as_team_update():
"""/team/{team_id} is reachable by org admins (in org_admin_allowed_routes) but
NOT by regular internal users or the role-agnostic self_managed_routes the
latter would open /team/new (the collision footgun) to any authenticated user."""
from litellm.proxy._types import LiteLLMRoutes
assert RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
)
assert not RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.internal_user_routes.value
)
assert not RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.self_managed_routes.value
)
def _patch_team_request() -> MagicMock:
request = MagicMock(spec=Request)
request.method = "PATCH"
request.query_params = {}
return request
def test_patch_team_gate_allows_org_admin_with_resolved_org():
"""Post-resolution, an org admin of the team's org clears the coarse gate for
PATCH /team/{team_id} parity with /team/update."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/team-1",
request=_patch_team_request(),
valid_token=valid_token,
request_data={"organization_id": "org-1"},
)
def test_patch_team_gate_rejects_regular_internal_user():
"""A plain internal user (not an org admin) is rejected at the coarse gate for
PATCH /team/{team_id}, even with the team's org resolved — injection alone is
not access. Same outcome as /team/update."""
user_obj = LiteLLM_UserTable(
user_id="regular-user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
organization_memberships=None,
)
valid_token = UserAPIKeyAuth(user_id="regular-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/team-1",
request=_patch_team_request(),
valid_token=valid_token,
request_data={"organization_id": "org-1"},
)
def test_patch_team_gate_rejects_cross_org_admin():
"""An org admin of a DIFFERENT org is rejected even after org resolution."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/team-1",
request=_patch_team_request(),
valid_token=valid_token,
request_data={"organization_id": "org-2"},
)
def test_patch_team_gate_rejects_view_only_admin():
"""A view-only proxy admin cannot PATCH a team (unsafe method), parity with the
/team/update view-only block."""
user_obj = LiteLLM_UserTable(
user_id="viewer",
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
)
valid_token = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value)
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
route="/team/team-1",
request=_patch_team_request(),
valid_token=valid_token,
request_data={"organization_id": "org-1"},
)
@pytest.mark.asyncio
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
"""

View file

@ -0,0 +1,94 @@
import copy
import pytest
from litellm.proxy.common_utils.json_merge_patch import _MAX_MERGE_DEPTH, 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}
def _nest(levels: int) -> dict:
"""A patch nested ``levels`` dicts deep with a scalar leaf at the bottom."""
value: object = "leaf"
for _ in range(levels):
value = {"a": value}
return value # type: ignore[return-value]
def test_merge_within_max_depth_is_allowed():
"""A deeply-but-not-pathologically nested patch merges without raising."""
result = apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH - 1))
for _ in range(_MAX_MERGE_DEPTH - 1):
result = result["a"]
assert result == "leaf"
def test_merge_beyond_max_depth_raises():
"""A patch nested past the cap fails closed (ValueError) rather than
overflowing the Python stack the guard the recursion detector requires."""
with pytest.raises(ValueError, match="maximum depth"):
apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH + 5))

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;