From e9246e924e156ee379d2a214a7e2a3ef39f9edfa Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 18:46:13 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 From 2c1d62ce2b586e047307c83d3ed79c64857287e8 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 11 Jul 2026 12:28:38 -0700 Subject: [PATCH 6/7] fix(rate-limit-v3): populate x-ratelimit-* remaining/limit values in standard_logging_object for streaming (LIT-4333) (#32711) Streaming requests return from common_request_processing before async_post_call_success_hook runs, so response._hidden_params.additional_headers never gets the v3 x-ratelimit-{descriptor_key}-{remaining|limit}-{rate_limit_type} entries. Prometheus / logging callbacks that read those values from standard_logging_object.hidden_params.additional_headers then see nothing; combined with the pre-existing gap that Prometheus reads from that same slot (LIT-2577 / PR #28816), per-key remaining RPM/TPM cannot be monitored for streaming traffic at all. Fix in three parts: - Stash the pre-call RateLimitResponse in the metadata channels the async success-logging callback inherits, alongside the existing top-level entry the non-streaming path reads. - Add async_logging_hook to the v3 handler. It fires in a distinct earlier loop inside async_success_handler (all callbacks' async_logging_hook complete before any async_log_success_event starts), so mirroring the pre-call snapshot into standard_logging_object.hidden_params.additional_headers and response._hidden_params.additional_headers here guarantees every downstream success callback sees the values regardless of registration order. Non-streaming keeps the existing async_post_call_success_hook write and this hook re-populates the same values idempotently. - Extract the shared `_merge_ratelimit_statuses_into_additional_headers` helper the non-streaming path already had inlined so both callsites emit the identical key shape. --- .../hooks/parallel_request_limiter_v3.py | 155 +++++++- .../hooks/test_parallel_request_limiter_v3.py | 338 ++++++++++++++++++ 2 files changed, 484 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index 7aedb74f2ea..d60c17c744f 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -244,6 +244,10 @@ TPM_RESERVED_SCOPES_KEY = "_litellm_tpm_reserved_scopes" # does not double-refund. TPM_RESERVATION_RELEASED_KEY = "_litellm_tpm_reservation_released" RATE_LIMIT_DESCRIPTORS_KEY = "_litellm_rate_limit_descriptors" +# Pre-call RateLimitResponse stashed here so streaming success logging can +# mirror ``x-ratelimit-*`` headers into the SLP. Streaming exits +# common_request_processing before ``async_post_call_success_hook`` runs. +RATE_LIMIT_RESPONSE_KEY = "_litellm_proxy_rate_limit_response" # Stash keys live ONLY in metadata channels — never at the top level of the # request body. Top-level keys are forwarded as body params to upstream # providers, which reject unknown fields with 400/429 errors. @@ -253,6 +257,7 @@ _LITELLM_STASH_KEYS: Tuple[str, ...] = ( TPM_RESERVED_SCOPES_KEY, TPM_RESERVATION_RELEASED_KEY, RATE_LIMIT_DESCRIPTORS_KEY, + RATE_LIMIT_RESPONSE_KEY, ) @@ -2037,6 +2042,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): else: # add descriptors to request headers data["litellm_proxy_rate_limit_response"] = response + # Mirror into metadata so streaming success logging can find + # it via ``kwargs["litellm_params"]["metadata"]``. + self._stash_value_in_metadata_channels( + data=data, + key=RATE_LIMIT_RESPONSE_KEY, + value=response, + ) # ---------------------------------------------------------------- # TPM token reservation @@ -2133,6 +2145,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): stored_response.setdefault("statuses", []).extend(tpm_response["statuses"]) elif tpm_response["statuses"]: data["litellm_proxy_rate_limit_response"] = tpm_response + # Keep the metadata stash in sync when this is the + # first snapshot written. + self._stash_value_in_metadata_channels( + data=data, + key=RATE_LIMIT_RESPONSE_KEY, + value=tpm_response, + ) verbose_proxy_logger.debug(f"TPM tokens reserved: {estimated_tokens} for model {requested_model}") @@ -2318,6 +2337,23 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): return "total" # default to total return specified_rate_limit_type + @staticmethod + def _merge_ratelimit_statuses_into_additional_headers( + additional_headers: Dict[str, Any], + statuses: List[RateLimitStatus], + ) -> Dict[str, Any]: + """ + Return ``additional_headers`` extended with + ``x-ratelimit-{descriptor_key}-{remaining|limit}-{rate_limit_type}`` + entries. Non-mutating so callers pick their own target dict. + """ + merged: Dict[str, Any] = dict(additional_headers) + for status in statuses: + prefix = f"x-ratelimit-{status['descriptor_key']}" + merged[f"{prefix}-remaining-{status['rate_limit_type']}"] = status["limit_remaining"] + merged[f"{prefix}-limit-{status['rate_limit_type']}"] = status["current_limit"] + return merged + @staticmethod def _stash_value_in_metadata_channels( data: Dict[str, Any], @@ -2698,6 +2734,112 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): except Exception as e: verbose_proxy_logger.exception(f"Error in rate limit success event: {str(e)}") + async def async_logging_hook( + self, + kwargs: dict, + result: Any, + call_type: str, + ) -> Tuple[dict, Any]: + """ + Mirror the pre-call rate-limit snapshot into the SLP so streaming + success callbacks see the same ``x-ratelimit-*`` headers the + non-streaming path writes via ``async_post_call_success_hook``. + Runs in the earlier of the two callback loops inside + ``async_success_handler`` so downstream callbacks see the values + regardless of registration order. Idempotent for non-streaming. + """ + self._mirror_ratelimit_response_into_logging_payload( + kwargs=kwargs, + response_obj=result, + ) + return kwargs, result + + def _mirror_ratelimit_response_into_logging_payload( + self, + kwargs: Any, + response_obj: Any, + ) -> None: + """ + Copy the stashed ``RateLimitResponse`` into the SLP's + ``hidden_params.additional_headers`` and the response object's + ``_hidden_params.additional_headers`` (when the latter is a dict). + """ + if not isinstance(kwargs, dict): + return + + standard_logging_object = kwargs.get("standard_logging_object") + standard_logging_metadata: Optional[Dict[str, Any]] = None + if isinstance(standard_logging_object, dict): + slp_metadata = standard_logging_object.get("metadata") + if isinstance(slp_metadata, dict): + standard_logging_metadata = slp_metadata + + statuses = self._narrow_ratelimit_statuses( + self._lookup_stashed_value( + kwargs=kwargs, + standard_logging_metadata=standard_logging_metadata, + key=RATE_LIMIT_RESPONSE_KEY, + ) + ) + if not statuses: + return + + if isinstance(standard_logging_object, dict): + hidden_params = standard_logging_object.get("hidden_params") + if not isinstance(hidden_params, dict): + hidden_params = {} + existing = hidden_params.get("additional_headers") + hidden_params["additional_headers"] = self._merge_ratelimit_statuses_into_additional_headers( + additional_headers=existing if isinstance(existing, dict) else {}, + statuses=statuses, + ) + standard_logging_object["hidden_params"] = hidden_params + + response_hidden = getattr(response_obj, "_hidden_params", None) + if isinstance(response_hidden, dict): + existing = response_hidden.get("additional_headers") + response_hidden["additional_headers"] = self._merge_ratelimit_statuses_into_additional_headers( + additional_headers=existing if isinstance(existing, dict) else {}, + statuses=statuses, + ) + + @staticmethod + def _narrow_ratelimit_statuses(stashed: Any) -> List[RateLimitStatus]: + """ + Narrow a stashed ``RateLimitResponse``-shaped dict to a typed + ``statuses`` list. Entries missing any header-write field are dropped; + an empty list means "nothing to mirror". + """ + if not isinstance(stashed, dict): + return [] + raw_statuses = stashed.get("statuses") + if not isinstance(raw_statuses, list): + return [] + narrowed: List[RateLimitStatus] = [] + for entry in raw_statuses: + if not isinstance(entry, dict): + continue + descriptor_key = entry.get("descriptor_key") + rate_limit_type = entry.get("rate_limit_type") + current_limit = entry.get("current_limit") + limit_remaining = entry.get("limit_remaining") + if ( + isinstance(descriptor_key, str) + and rate_limit_type in ("requests", "tokens", "max_parallel_requests") + and isinstance(current_limit, int) + and isinstance(limit_remaining, int) + ): + narrowed.append( + RateLimitStatus( + code=entry.get("code", "OK") if isinstance(entry.get("code"), str) else "OK", + current_limit=current_limit, + limit_remaining=limit_remaining, + rate_limit_type=rate_limit_type, + descriptor_key=descriptor_key, + ) + ) + return narrowed + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): """ On failure: decrement max_parallel_requests and refund the upfront @@ -2838,15 +2980,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): if isinstance(_hidden_params, BaseModel): _hidden_params = _hidden_params.model_dump() - _additional_headers = _hidden_params.get("additional_headers", {}) or {} - - # Add rate limit headers - for status in litellm_proxy_rate_limit_response["statuses"]: - prefix = f"x-ratelimit-{status['descriptor_key']}" - _additional_headers[f"{prefix}-remaining-{status['rate_limit_type']}"] = status[ - "limit_remaining" - ] - _additional_headers[f"{prefix}-limit-{status['rate_limit_type']}"] = status["current_limit"] + _additional_headers = self._merge_ratelimit_statuses_into_additional_headers( + additional_headers=_hidden_params.get("additional_headers", {}) or {}, + statuses=litellm_proxy_rate_limit_response["statuses"], + ) setattr( response, diff --git a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py index d150591c8de..e7d2909263a 100644 --- a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py @@ -3706,3 +3706,341 @@ async def test_per_tag_untagged_request_governed_by_key_limit_v3(monkeypatch): await call({"tags": ["cell-99"]}) assert exc_info.value.status_code == 429 assert "tag_per_key" not in str(exc_info.value.detail) + + +# -------------------------------------------------------------------------- +# Streaming success logging mirrors x-ratelimit-* remaining values into +# standard_logging_object.hidden_params.additional_headers so Prometheus / +# logging callbacks see them for streams too (non-streaming already gets +# them via async_post_call_success_hook, which the streaming path skips). +# -------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_streaming_end_to_end_populates_slp_ratelimit_headers(monkeypatch): + """ + End-to-end regression: on a streaming request, the same pre-call + + success-callback pair the proxy uses must land ``x-ratelimit-*`` + remaining/limit values in + ``kwargs["standard_logging_object"]["hidden_params"]["additional_headers"]``. + """ + monkeypatch.setenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", "60") + _api_key = hash_token("sk-stream-e2e") + user_api_key_dict = UserAPIKeyAuth( + api_key=_api_key, + rpm_limit=100, + tpm_limit=10000, + ) + local_cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(local_cache) + ) + + # Real pre-call: populates data and stashes the response into metadata + # so the success callback can find it via litellm_params.metadata. + data: Dict[str, Any] = { + "model": "gpt-4o-mini", + "metadata": {}, + "stream": True, + } + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data=data, + call_type="", + ) + + # Simulate the wrapper handing the pre-call metadata dict to the + # completion() call: it becomes kwargs["litellm_params"]["metadata"] by + # the time the success callback fires. + mock_response = ModelResponse( + id="mock-stream-e2e", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="gpt-4o-mini", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + mock_kwargs: Dict[str, Any] = { + "standard_logging_object": { + "metadata": { + "user_api_key_hash": _api_key, + "user_api_key_user_id": None, + "user_api_key_team_id": None, + "user_api_key_end_user_id": None, + } + }, + "litellm_params": {"metadata": data["metadata"]}, + "model": "gpt-4o-mini", + } + + async def _noop_increment(increment_list, **_): + return True + + monkeypatch.setattr( + handler.internal_usage_cache.dual_cache, + "async_increment_cache_pipeline", + _noop_increment, + ) + + # async_logging_hook runs before async_log_success_event, so any + # downstream callback that reads the SLP sees the mirrored values. + await handler.async_logging_hook( + kwargs=mock_kwargs, + result=mock_response, + call_type="acompletion", + ) + + additional_headers = ( + mock_kwargs["standard_logging_object"] + .get("hidden_params", {}) + .get("additional_headers", {}) + ) + + # api_key-scoped remaining/limit values are the baseline every request + # emits and must always reach the SLP. + remaining_keys = [ + k for k in additional_headers if "-remaining-" in k + ] + assert ( + remaining_keys + ), f"streaming success must populate remaining values, got {additional_headers!r}" + limit_keys = [k for k in additional_headers if "-limit-" in k] + assert limit_keys, "streaming success must also populate limit values" + assert ( + additional_headers.get("x-ratelimit-api_key-remaining-requests") == 99 + ), ( + "api_key remaining requests should reflect the just-consumed slot;" + f" got {additional_headers!r}" + ) + + +@pytest.mark.asyncio +async def test_streaming_populates_model_per_key_ratelimit_headers(monkeypatch): + """ + Streaming must land the per-(key, model) remaining/limit values in the + SLP under ``x-ratelimit-model_per_key-{remaining|limit}-{requests,tokens}``. + """ + monkeypatch.setenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", "60") + _api_key = hash_token("sk-stream-mirror") + user_api_key_dict = UserAPIKeyAuth( + api_key=_api_key, + metadata={ + "model_rpm_limit": {"gpt-4o-mini": 100}, + "model_tpm_limit": {"gpt-4o-mini": 10000}, + }, + ) + local_cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(local_cache) + ) + + async def _noop_increment(increment_list, **_): + return True + + monkeypatch.setattr( + handler.internal_usage_cache.dual_cache, + "async_increment_cache_pipeline", + _noop_increment, + ) + + data: Dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}, "stream": True} + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data=data, + call_type="", + ) + + mock_response = ModelResponse( + id="mock-stream", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="gpt-4o-mini", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + + mock_kwargs: Dict[str, Any] = { + "standard_logging_object": { + "metadata": { + "user_api_key_hash": _api_key, + "user_api_key_user_id": None, + "user_api_key_team_id": None, + "user_api_key_end_user_id": None, + } + }, + "litellm_params": {"metadata": data["metadata"]}, + "model": "gpt-4o-mini", + } + + await handler.async_logging_hook( + kwargs=mock_kwargs, + result=mock_response, + call_type="acompletion", + ) + + hidden_params = mock_kwargs["standard_logging_object"].get("hidden_params") or {} + additional_headers = hidden_params.get("additional_headers") or {} + + assert ( + additional_headers.get("x-ratelimit-model_per_key-remaining-requests") == 99 + ), f"got {additional_headers!r}" + assert additional_headers.get("x-ratelimit-model_per_key-limit-requests") == 100 + + # response._hidden_params is also updated for late readers. + response_hidden = getattr(mock_response, "_hidden_params", None) or {} + response_headers = response_hidden.get("additional_headers") or {} + assert response_headers.get("x-ratelimit-model_per_key-remaining-requests") == 99 + + +@pytest.mark.asyncio +async def test_async_log_success_event_no_mirror_when_no_snapshot(monkeypatch): + """ + No pre-call snapshot (no descriptors matched) -> no fabricated + ``x-ratelimit-*`` headers. + """ + monkeypatch.setenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", "60") + _api_key = hash_token("sk-stream-no-mirror") + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + + async def _noop_increment(increment_list, **_): + return True + + monkeypatch.setattr( + handler.internal_usage_cache.dual_cache, + "async_increment_cache_pipeline", + _noop_increment, + ) + + mock_response = ModelResponse( + id="mock-stream-none", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="gpt-4o-mini", + usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2), + choices=[], + ) + + mock_kwargs: Dict[str, Any] = { + "standard_logging_object": { + "metadata": { + "user_api_key_hash": _api_key, + "user_api_key_user_id": None, + "user_api_key_team_id": None, + "user_api_key_end_user_id": None, + } + }, + "litellm_params": {"metadata": {}}, + "model": "gpt-4o-mini", + } + + await handler.async_logging_hook( + kwargs=mock_kwargs, + result=mock_response, + call_type="acompletion", + ) + + hidden_params = mock_kwargs["standard_logging_object"].get("hidden_params") or {} + additional_headers = hidden_params.get("additional_headers") or {} + ratelimit_keys = [k for k in additional_headers if k.startswith("x-ratelimit-")] + assert ( + not ratelimit_keys + ), f"no snapshot must produce no rate-limit headers, got {ratelimit_keys}" + + +@pytest.mark.asyncio +async def test_streaming_mirror_matches_non_streaming_header_shape(monkeypatch): + """ + Given the same pre-call state, streaming and non-streaming must write + the identical ``x-ratelimit-*`` key/value shape to their respective + ``additional_headers`` slots. + """ + monkeypatch.setenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", "60") + _api_key = hash_token("sk-shape") + user_api_key_dict = UserAPIKeyAuth( + api_key=_api_key, + metadata={ + "model_rpm_limit": {"gpt-4o-mini": 50}, + "model_tpm_limit": {"gpt-4o-mini": 5000}, + }, + ) + local_cache = DualCache() + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(local_cache) + ) + + async def _noop_increment(increment_list, **_): + return True + + monkeypatch.setattr( + handler.internal_usage_cache.dual_cache, + "async_increment_cache_pipeline", + _noop_increment, + ) + + # Drive pre-call once so both paths have the same authoritative snapshot. + data: Dict[str, Any] = {"model": "gpt-4o-mini", "metadata": {}} + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=local_cache, + data=data, + call_type="", + ) + + # Non-streaming path: async_post_call_success_hook mutates response._hidden_params. + non_stream_response = ModelResponse( + id="mock-non-stream", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="gpt-4o-mini", + usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2), + choices=[], + ) + non_stream_response._hidden_params = {} + await handler.async_post_call_success_hook( + data=data, + user_api_key_dict=user_api_key_dict, + response=non_stream_response, + ) + non_stream_headers = non_stream_response._hidden_params.get( + "additional_headers", {} + ) + + # Streaming path: async_logging_hook mirrors into standard_logging_object. + stream_kwargs: Dict[str, Any] = { + "standard_logging_object": { + "metadata": {"user_api_key_hash": _api_key} + }, + "litellm_params": {"metadata": data["metadata"]}, + "model": "gpt-4o-mini", + } + stream_response = ModelResponse( + id="mock-stream", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="gpt-4o-mini", + usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2), + choices=[], + ) + await handler.async_logging_hook( + kwargs=stream_kwargs, + result=stream_response, + call_type="acompletion", + ) + stream_slp_headers = ( + stream_kwargs["standard_logging_object"] + .get("hidden_params", {}) + .get("additional_headers", {}) + ) + + def _rl_only(headers: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in headers.items() if k.startswith("x-ratelimit-")} + + assert _rl_only(stream_slp_headers) == _rl_only(non_stream_headers), ( + f"streaming={_rl_only(stream_slp_headers)}" + f" non_streaming={_rl_only(non_stream_headers)}" + ) + assert "x-ratelimit-model_per_key-remaining-requests" in stream_slp_headers From 345d3539120805148bc24902b3cea197b0a3994e Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:57:37 -0700 Subject: [PATCH 7/7] test: remove live OpenAI fine-tuning job-creation test blocked by platform wind-down (#32933) OpenAI is winding down self-serve fine-tuning and the org can no longer create fine-tuning jobs (403 training_not_available; the CI key surfaces it as a 500 server_error), so test_create_fine_tune_jobs_async fails on every batches_testing run since 2026-07-11 and reruns never clear it. The request contract stays covered by the mocked create/list/cancel/ retrieve tests in the same file, and the deleted test's unique standard_logging_object assertions now run inside test_mock_openai_create_fine_tune_job. --- tests/batches_tests/test_fine_tuning_api.py | 128 +++----------------- 1 file changed, 16 insertions(+), 112 deletions(-) diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index c489685eaff..bd6672a52e9 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -13,13 +13,10 @@ import litellm litellm.num_retries = 0 import asyncio -import logging from typing import Optional -import openai from test_openai_batches_and_files import load_vertex_ai_credentials from litellm import create_fine_tuning_job -from litellm._logging import verbose_logger from litellm.llms.vertex_ai.fine_tuning.handler import ( FineTuningJobCreate, VertexFineTuningAPI, @@ -47,115 +44,6 @@ class TestCustomLogger(CustomLogger): self.standard_logging_object = kwargs["standard_logging_object"] -async def _acreate_fine_tuning_job_with_propagation_retry( - *, max_attempts: int = 12, initial_delay: float = 1.0, **kwargs -): - """ - Wrap litellm.acreate_fine_tuning_job and retry on the eventual-consistency - 400 OpenAI returns when a freshly-uploaded training file isn't yet visible - to the fine-tuning endpoint (`'file-... does not exist'`). - - Polling the files-retrieve endpoint or `FileObject.status` doesn't help — - OpenAI's `status` field is deprecated, and the retrieve and fine-tuning - endpoints don't share a consistency model. Retrying the operation itself - is the only reliable signal that propagation has finished. - - Total budget with defaults: ~70s across 12 attempts (exp backoff capped at - 8s). - """ - delay = initial_delay - last_error: Optional[openai.BadRequestError] = None - for _ in range(max_attempts): - try: - return await litellm.acreate_fine_tuning_job(**kwargs) - except openai.BadRequestError as e: - if "does not exist" not in str(e): - raise - last_error = e - await asyncio.sleep(delay) - delay = min(delay * 1.5, 8.0) - assert last_error is not None - raise last_error - - -@pytest.mark.asyncio -async def test_create_fine_tune_jobs_async(): - try: - custom_logger = TestCustomLogger() - litellm.callbacks = ["datadog", custom_logger] - verbose_logger.setLevel(logging.DEBUG) - file_name = "openai_batch_completions.jsonl" - _current_dir = os.path.dirname(os.path.abspath(__file__)) - file_path = os.path.join(_current_dir, file_name) - - file_obj = await litellm.acreate_file( - file=open(file_path, "rb"), - purpose="fine-tune", - custom_llm_provider="openai", - ) - print("Response from creating file=", file_obj) - - create_fine_tuning_response = ( - await _acreate_fine_tuning_job_with_propagation_retry( - model="gpt-4o-mini-2024-07-18", - training_file=file_obj.id, - ) - ) - - print( - "response from litellm.create_fine_tuning_job=", create_fine_tuning_response - ) - - assert create_fine_tuning_response.id is not None - assert create_fine_tuning_response.model == "gpt-4o-mini-2024-07-18" - - await asyncio.sleep(2) - _logged_standard_logging_object = custom_logger.standard_logging_object - assert _logged_standard_logging_object is not None - print( - "custom_logger.standard_logging_object=", - json.dumps(_logged_standard_logging_object, indent=4), - ) - assert _logged_standard_logging_object["model"] == "gpt-4o-mini-2024-07-18" - assert _logged_standard_logging_object["id"] == create_fine_tuning_response.id - - # list fine tuning jobs - print("listing ft jobs") - ft_jobs = await litellm.alist_fine_tuning_jobs(limit=2) - print("response from litellm.list_fine_tuning_jobs=", ft_jobs) - assert len(list(ft_jobs)) > 0 - - # retrieve fine tuning job - response = await litellm.aretrieve_fine_tuning_job( - fine_tuning_job_id=create_fine_tuning_response.id, - ) - print("response from litellm.retrieve_fine_tuning_job=", response) - - # delete file - - await litellm.afile_delete( - file_id=file_obj.id, - ) - - # cancel ft job - response = await litellm.acancel_fine_tuning_job( - fine_tuning_job_id=create_fine_tuning_response.id, - ) - - print("response from litellm.cancel_fine_tuning_job=", response) - - assert response.status == "cancelled" - assert response.id == create_fine_tuning_response.id - except openai.RateLimitError: - pass - except Exception as e: - if "Job has already completed" in str(e): - return - else: - pytest.fail(f"Error occurred: {e}") - pass - - @pytest.mark.asyncio() async def test_create_vertex_fine_tune_jobs_mocked(): # Define reusable variables for the test @@ -455,6 +343,9 @@ async def test_mock_openai_create_fine_tune_job(): from openai import AsyncOpenAI from openai.types.fine_tuning.fine_tuning_job import FineTuningJob, Hyperparameters + custom_logger = TestCustomLogger() + previous_callbacks = litellm.callbacks + litellm.callbacks = [custom_logger] client = AsyncOpenAI(api_key="fake-api-key") with patch.object(client.fine_tuning.jobs, "create") as mock_create: @@ -500,6 +391,19 @@ async def test_mock_openai_create_fine_tune_job(): == "ft:gpt-4o-mini-2024-07-18:org:custom_suffix:id" ) + try: + for _ in range(20): + if custom_logger.standard_logging_object is not None: + break + await asyncio.sleep(0.25) + logged = custom_logger.standard_logging_object + assert logged is not None + assert logged["model"] == "gpt-4o-mini-2024-07-18" + assert logged["id"] == response.id + assert logged["call_type"] == "acreate_fine_tuning_job" + finally: + litellm.callbacks = previous_callbacks + @pytest.mark.asyncio async def test_mock_openai_list_fine_tune_jobs():