mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
2e45fc5919
10 changed files with 783 additions and 5 deletions
|
|
@ -589,6 +589,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# team
|
||||
"/team/new",
|
||||
"/team/update",
|
||||
"/team/{team_id}",
|
||||
"/team/delete",
|
||||
"/team/list",
|
||||
"/v2/team/list",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
33
litellm/proxy/common_utils/json_merge_patch.py
Normal file
33
litellm/proxy/common_utils/json_merge_patch.py
Normal 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}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue