From e9246e924e156ee379d2a214a7e2a3ef39f9edfa Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 18:46:13 -0700 Subject: [PATCH 1/5] 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 --- .../proxy/common_utils/json_merge_patch.py | 21 ++ .../management_endpoints/team_endpoints.py | 84 ++++- .../common_utils/test_json_merge_patch.py | 71 ++++ .../test_team_endpoints.py | 329 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 66 ++++ 5 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/common_utils/json_merge_patch.py create mode 100644 tests/test_litellm/proxy/common_utils/test_json_merge_patch.py diff --git a/litellm/proxy/common_utils/json_merge_patch.py b/litellm/proxy/common_utils/json_merge_patch.py new file mode 100644 index 00000000000..24a025eb410 --- /dev/null +++ b/litellm/proxy/common_utils/json_merge_patch.py @@ -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} diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 8ec7ec707a2..f468f5ec30b 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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: diff --git a/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py new file mode 100644 index 00000000000..89c2d474ed3 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py @@ -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} diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 180fb1d3d8f..59b08a7ec4e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 15ddc5858c2..2cee78a6efb 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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; From 8f0dcb23f4e598265e2661b2bd98670c4432bffd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 23:14:01 -0700 Subject: [PATCH 2/5] feat(team): let org admins reach PATCH /team/{team_id} like POST /team/update Wire the coarse route gate so PATCH /team/{team_id} is reachable by exactly the roles that can call POST /team/update: proxy admins, org admins of the team's own organization, and JWT admins. Regular internal users and view-only proxy admins stay blocked, matching the existing endpoint Because the team id lives in the path rather than the body, the org-context resolver now also reads it from path_team_id for the bare /team/{team_id} route, so an org admin's organization is resolved and injected the same way it already is for POST /team/update. /team/{team_id} is added to management_routes rather than the role-agnostic self_managed_routes; the latter would have opened POST /team/new to any authenticated user through the shared /team/{team_id} path pattern --- litellm/proxy/_types.py | 1 + litellm/proxy/auth/auth_checks.py | 1 + .../proxy/auth/auth_checks_organization.py | 17 +- .../proxy/auth/test_route_checks.py | 156 ++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f441a1d3f84..23fe7730c17 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -589,6 +589,7 @@ class LiteLLMRoutes(enum.Enum): # team "/team/new", "/team/update", + "/team/{team_id}", "/team/delete", "/team/list", "/v2/team/list", diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index cd8103abf5e..fab0338c7a8 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -726,6 +726,7 @@ async def common_checks( route=route, request_body=request_body, fetch_team_org_id=_fetch_team_org_id, + path_team_id=request.path_params.get("team_id"), ) _is_route_allowed = _is_api_route_allowed( diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index b4caff9b8ee..bb82b3a142d 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -2,6 +2,7 @@ Auth Checks for Organizations """ +import re from typing import Awaitable, Callable, Dict, List, Optional, Tuple from fastapi import status @@ -173,12 +174,14 @@ def _user_is_org_admin( TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"}) +_TEAM_ID_PATH_ROUTE = re.compile(r"^/team/[^/]+$") async def add_team_org_context_to_request_body( route: str, request_body: dict, fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]], + path_team_id: Optional[str] = None, ) -> dict: """ Return a copy of request_body with organization_id resolved from the target @@ -188,12 +191,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 + path (``path_team_id``) for the bare ``/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 path_team_id and _TEAM_ID_PATH_ROUTE.match(route): + team_id = path_team_id + 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) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 204e6a671e3..404701bb8ac 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2724,6 +2724,162 @@ 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 path_team_id 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, + path_team_id="team-1", + ) + 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 is NOT the bare team route, so + no org is injected even though a team_id path param is present.""" + + 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, + path_team_id="team-1", + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_path_noop_without_path_team_id(): + """Routes with no team_id path param (e.g. POST /team/new, whose path also + matches the bare shape) resolve nothing.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve when there is no path team_id") + + 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, + path_team_id=None, + ) + 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(): """ From 6875ca00d07931abe63348528b900ae28f835224 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Jul 2026 09:36:15 -0700 Subject: [PATCH 3/5] fix(team): bound json merge patch recursion depth apply_json_merge_patch recurses into nested objects, which the repo's recursive_detector code-quality check flags because unbounded recursion over caller-supplied JSON has caused CPU/stack issues before. Cap the recursion at a depth far above any realistic team-metadata shape and reject deeper patches with a ValueError so a pathologically nested body fails closed instead of overflowing the stack, then register the function in the detector's ignore list alongside the other depth-bounded JSON walkers --- .../proxy/common_utils/json_merge_patch.py | 18 ++++++++++--- .../code_coverage_tests/recursive_detector.py | 1 + .../common_utils/test_json_merge_patch.py | 25 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/common_utils/json_merge_patch.py b/litellm/proxy/common_utils/json_merge_patch.py index 24a025eb410..ec576a8ec70 100644 --- a/litellm/proxy/common_utils/json_merge_patch.py +++ b/litellm/proxy/common_utils/json_merge_patch.py @@ -2,20 +2,32 @@ 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) -> JsonValue: + +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. + ``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) for key, value in patch.items() if value is not None} + 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} diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 2af14e1e544..f9bd4a012cc 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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. ] diff --git a/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py index 89c2d474ed3..d4b60d6a1e7 100644 --- a/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py +++ b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py @@ -2,7 +2,7 @@ import copy import pytest -from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch +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 @@ -69,3 +69,26 @@ def test_scalar_patch_replaces_object_wholesale(): 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)) From c9259e4bf2b850ecc8b7cea90313422ff010b6c0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Jul 2026 09:44:31 -0700 Subject: [PATCH 4/5] fix(auth): tolerate request objects without path_params in common_checks The PATCH /team/{team_id} org-context wiring reads request.path_params to resolve the team id from the path. A real Starlette Request always exposes path_params, but common_checks is exercised with lightweight request doubles that don't, which raised AttributeError. Read it defensively so a missing or null path_params falls back to no path team id, matching the "not a bare team route" outcome; real requests are unaffected --- litellm/proxy/auth/auth_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index fab0338c7a8..1db5e2f5724 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -726,7 +726,7 @@ async def common_checks( route=route, request_body=request_body, fetch_team_org_id=_fetch_team_org_id, - path_team_id=request.path_params.get("team_id"), + path_team_id=(getattr(request, "path_params", None) or {}).get("team_id"), ) _is_route_allowed = _is_api_route_allowed( From 368342cdde895bce9009d9f1d8f3d5930f88a34d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Jul 2026 11:24:41 -0700 Subject: [PATCH 5/5] refactor(auth): resolve PATCH team org-context from the route template Replace the request.path_params read (and its defensive getattr guard) with the route template. A real Starlette request always exposes path_params, but common_checks runs on lightweight request doubles that don't, so reading it directly forced a getattr workaround that only existed to tolerate those doubles. Instead, match the route template (/team/{team_id}) to identify the RESTful update route and take the team id from the last path segment. This drops the path_params dependency entirely, and because the template distinguishes the PATCH route from its single-segment siblings (/team/new, /team/list, ...), it also avoids a spurious team lookup those routes would otherwise trigger if we matched the resolved path shape alone. --- litellm/proxy/auth/auth_checks.py | 4 ++-- .../proxy/auth/auth_checks_organization.py | 14 ++++++----- .../proxy/auth/test_route_checks.py | 24 ++++++++++--------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1db5e2f5724..230b9b70ff0 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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,7 +726,7 @@ async def common_checks( route=route, request_body=request_body, fetch_team_org_id=_fetch_team_org_id, - path_team_id=(getattr(request, "path_params", None) or {}).get("team_id"), + route_template=get_request_route_template(request), ) _is_route_allowed = _is_api_route_allowed( diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index bb82b3a142d..9b9e0661084 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -2,7 +2,6 @@ Auth Checks for Organizations """ -import re from typing import Awaitable, Callable, Dict, List, Optional, Tuple from fastapi import status @@ -174,14 +173,17 @@ def _user_is_org_admin( TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"}) -_TEAM_ID_PATH_ROUTE = re.compile(r"^/team/[^/]+$") +# The RESTful update route carries the team id in the path. Match on the route +# template so the sibling /team/ 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]]], - path_team_id: Optional[str] = None, + route_template: Optional[str] = None, ) -> dict: """ Return a copy of request_body with organization_id resolved from the target @@ -193,15 +195,15 @@ async def add_team_org_context_to_request_body( are untouched. The team_id is taken from the body for TEAM_ORG_CONTEXT_ROUTES, or from the - path (``path_team_id``) for the bare ``/team/{team_id}`` route. + last path segment when ``route_template`` is the ``/team/{team_id}`` route. """ if request_body.get("organization_id"): return request_body if route in TEAM_ORG_CONTEXT_ROUTES: team_id: Optional[str] = request_body.get("team_id") - elif path_team_id and _TEAM_ID_PATH_ROUTE.match(route): - team_id = path_team_id + elif route_template == PATCH_TEAM_ROUTE_TEMPLATE: + team_id = route.rsplit("/", 1)[-1] else: return request_body diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 404701bb8ac..a6d4dc63697 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2730,8 +2730,9 @@ def test_team_update_gate_rejects_cross_org_admin_with_resolved_org(): @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 path_team_id and injected, so an org admin of that - team's org clears the same gate they clear for POST /team/update.""" + 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" @@ -2741,15 +2742,15 @@ async def test_add_team_org_context_resolves_org_from_path_for_patch_route(): route="/team/team-1", request_body={"metadata": {"cost_center": "x"}}, fetch_team_org_id=fetch, - path_team_id="team-1", + 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 is NOT the bare team route, so - no org is injected even though a team_id path param is present.""" + """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") @@ -2759,25 +2760,26 @@ async def test_add_team_org_context_path_noop_for_team_subresource(): route="/team/team-1/members/me", request_body=body, fetch_team_org_id=fetch, - path_team_id="team-1", + route_template="/team/{team_id}/members/me", ) assert out == body @pytest.mark.asyncio -async def test_add_team_org_context_path_noop_without_path_team_id(): - """Routes with no team_id path param (e.g. POST /team/new, whose path also - matches the bare shape) resolve nothing.""" +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/ call.""" async def fetch(team_id: str): - raise AssertionError("must not resolve when there is no path team_id") + raise AssertionError("must not resolve for a static /team/ 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, - path_team_id=None, + route_template="/team/new", ) assert out == body