mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
14338a2471
commit
e9246e924e
5 changed files with 570 additions and 1 deletions
21
litellm/proxy/common_utils/json_merge_patch.py
Normal file
21
litellm/proxy/common_utils/json_merge_patch.py
Normal 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}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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