Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_complexity_router_keyword_tiers

This commit is contained in:
mateo-berri 2026-07-11 20:26:16 +00:00
commit fdaee89702
No known key found for this signature in database
13 changed files with 1283 additions and 126 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
"""RFC 7386 JSON Merge Patch (https://www.rfc-editor.org/rfc/rfc7386)."""
from pydantic import JsonValue
# A merge patch recurses as deep as the client's JSON nests. Cap it far above any
# realistic team-metadata shape but well below Python's stack limit, so a
# pathologically deep patch is rejected instead of overflowing the stack.
_MAX_MERGE_DEPTH = 64
def apply_json_merge_patch(target: JsonValue, patch: JsonValue, _depth: int = 0) -> JsonValue:
"""Apply an RFC 7386 JSON Merge Patch to ``target`` and return the result.
- a key absent from ``patch`` keeps its value in ``target``
- a key mapped to ``null`` in ``patch`` is removed from the result
- any other value overwrites, recursing into nested objects
``target`` is never mutated; a new value is returned. Raises ``ValueError``
if ``patch`` nests deeper than ``_MAX_MERGE_DEPTH``.
"""
if not isinstance(patch, dict):
return patch
if _depth >= _MAX_MERGE_DEPTH:
raise ValueError(f"JSON merge patch nesting exceeds the maximum depth of {_MAX_MERGE_DEPTH}")
base = target if isinstance(target, dict) else {}
preserved = {key: value for key, value in base.items() if key not in patch}
applied = {
key: apply_json_merge_patch(base.get(key), value, _depth + 1)
for key, value in patch.items()
if value is not None
}
return {**preserved, **applied}

View file

@ -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,

View file

@ -14,7 +14,7 @@ import json
import math
import traceback
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from typing import Annotated, Any, Dict, List, Optional, Tuple, Union, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -76,6 +76,7 @@ from litellm.proxy.auth.auth_checks import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
_is_user_org_admin_for_team,
@ -1936,6 +1937,87 @@ async def update_team(
raise handle_exception_on_proxy(e)
@router.patch(
"/team/{team_id}",
tags=["team management"],
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_TeamTable,
)
async def patch_team(
team_id: str,
http_request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
litellm_changed_by: Annotated[
Optional[str],
Header(
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
] = None,
):
"""
Partially update a team using RFC 7386 JSON Merge Patch semantics.
`team_id` is taken from the path. `metadata` is merged with the team's stored
metadata rather than replacing it: an omitted key is preserved, `key: null`
deletes it, and any other value overwrites (recursing into nested objects).
Every other field behaves exactly like `POST /team/update` (omitted preserves,
a value overwrites). Returns the full updated team.
```
curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data-raw '{
"metadata": {"cost_center": "1234", "deprecated_key": null}
}'
```
"""
from litellm.proxy.proxy_server import prisma_client
try:
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
try:
body = await http_request.json()
except (json.JSONDecodeError, ValueError):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
body_team_id = body.pop("team_id", None)
if body_team_id is not None and body_team_id != team_id:
raise HTTPException(
status_code=400,
detail={"error": f"team_id in body ({body_team_id}) does not match team_id in path ({team_id})"},
)
if "metadata" in body:
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
if existing_team_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Team not found, passed team_id={team_id}"},
)
existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {}
body["metadata"] = apply_json_merge_patch(existing_metadata, body["metadata"])
update_request = UpdateTeamRequest(team_id=team_id, **body)
result = await update_team(
data=update_request,
http_request=http_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
return result["data"]
except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract
raise handle_exception_on_proxy(e)
def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None:
"""Set budget_reset_at in updated_kv if budget_duration is provided."""
if data.budget_duration is not None:

View file

@ -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():

View file

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

View file

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

View file

@ -0,0 +1,94 @@
import copy
import pytest
from litellm.proxy.common_utils.json_merge_patch import _MAX_MERGE_DEPTH, apply_json_merge_patch
# RFC 7386 Appendix A — the normative test suite for JSON Merge Patch.
# https://www.rfc-editor.org/rfc/rfc7386#appendix-A
RFC_7386_APPENDIX_A = [
({"a": "b"}, {"a": "c"}, {"a": "c"}),
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}),
({"a": "b"}, {"a": None}, {}),
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}),
({"a": ["b"]}, {"a": "c"}, {"a": "c"}),
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}),
({"a": {"b": "c"}}, {"a": {"b": "d", "c": None}}, {"a": {"b": "d"}}),
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}),
(["a", "b"], ["c", "d"], ["c", "d"]),
({"a": "b"}, ["c"], ["c"]),
({"a": "foo"}, None, None),
({"a": "foo"}, "bar", "bar"),
({"e": None}, {"a": 1}, {"e": None, "a": 1}),
([1, 2], {"a": "b", "c": None}, {"a": "b"}),
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}),
]
@pytest.mark.parametrize("target, patch, expected", RFC_7386_APPENDIX_A)
def test_rfc_7386_appendix_a(target, patch, expected):
assert apply_json_merge_patch(target, patch) == expected
def test_does_not_mutate_target():
"""The target must be treated as immutable — a fresh value is returned."""
target = {"keep": "me", "nested": {"a": 1, "b": 2}, "drop": "later"}
target_snapshot = copy.deepcopy(target)
result = apply_json_merge_patch(target, {"nested": {"b": None, "c": 3}, "drop": None})
assert target == target_snapshot, "apply_json_merge_patch mutated its target argument"
assert result == {"keep": "me", "nested": {"a": 1, "c": 3}}
assert result["nested"] is not target["nested"]
def test_absent_key_is_preserved_but_null_key_is_deleted():
"""The core distinction PATCH relies on: omission preserves, explicit null deletes."""
target = {"cost_center": "1234", "team": "core"}
# Omitting cost_center preserves it; only the explicitly-null key is removed.
assert apply_json_merge_patch(target, {"team": "platform"}) == {
"cost_center": "1234",
"team": "platform",
}
assert apply_json_merge_patch(target, {"cost_center": None}) == {"team": "core"}
def test_deep_nested_merge_and_delete():
target = {"limits": {"gpt-4": {"rpm": 100, "tpm": 1000}, "gpt-3.5": {"rpm": 200}}}
patch = {"limits": {"gpt-4": {"tpm": 2000}, "gpt-3.5": None, "claude": {"rpm": 50}}}
assert apply_json_merge_patch(target, patch) == {
"limits": {"gpt-4": {"rpm": 100, "tpm": 2000}, "claude": {"rpm": 50}}
}
def test_scalar_patch_replaces_object_wholesale():
assert apply_json_merge_patch({"a": {"b": 1}}, 5) == 5
def test_object_patch_over_non_object_target_starts_from_empty():
assert apply_json_merge_patch("not-an-object", {"a": 1, "b": None}) == {"a": 1}
def _nest(levels: int) -> dict:
"""A patch nested ``levels`` dicts deep with a scalar leaf at the bottom."""
value: object = "leaf"
for _ in range(levels):
value = {"a": value}
return value # type: ignore[return-value]
def test_merge_within_max_depth_is_allowed():
"""A deeply-but-not-pathologically nested patch merges without raising."""
result = apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH - 1))
for _ in range(_MAX_MERGE_DEPTH - 1):
result = result["a"]
assert result == "leaf"
def test_merge_beyond_max_depth_raises():
"""A patch nested past the cap fails closed (ValueError) rather than
overflowing the Python stack the guard the recursion detector requires."""
with pytest.raises(ValueError, match="maximum depth"):
apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH + 5))

View file

@ -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

View file

@ -9535,3 +9535,332 @@ async def test_new_team_rejects_reserved_ui_session_team_id():
assert exc_info.value.code == "400"
assert "reserved" in str(exc_info.value.message)
mock_prisma.get_data.assert_not_called()
# ---------------------------------------------------------------------------
# PATCH /team/{team_id} — RFC 7386 JSON Merge Patch
#
# The new PATCH endpoint delegates to the same write path as POST /team/update;
# the single intended divergence is metadata. POST replaces the metadata column
# wholesale, PATCH merges it per RFC 7386 (omit preserves, null deletes, value
# overwrites, recursing into nested objects). Every other field must behave
# identically. Each test drives BOTH endpoints against an identical mocked team
# and asserts on the exact dict handed to litellm_teamtable.update.
# ---------------------------------------------------------------------------
_PATCH_TEAM_ID = "team-merge-patch-test"
_ABSENT = object()
async def _drive_team_write(
kind,
*,
existing_metadata=None,
existing_kwargs=None,
payload=None,
raw_body=None,
user=None,
find_returns_none=False,
json_side_effect=None,
):
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
"""
from unittest.mock import AsyncMock, MagicMock, Mock
from unittest.mock import patch as _patch
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
UpdateTeamRequest,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import (
patch_team,
update_team,
)
existing = LiteLLM_TeamTable(
team_id=_PATCH_TEAM_ID,
team_alias="t",
metadata=existing_metadata,
organization_id=None,
**(existing_kwargs or {}),
)
auth = user or UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u")
with (
_patch("litellm.proxy.proxy_server.prisma_client") as pc,
_patch("litellm.proxy.proxy_server.llm_router", None),
_patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
_patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
_patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
_patch(
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
new=AsyncMock(),
),
):
pc.db.litellm_teamtable.find_unique = AsyncMock(
return_value=None if find_returns_none else existing
)
pc.db.litellm_teamtable.update = AsyncMock(
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
)
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
req = Mock(spec=Request)
if kind == "post":
result = await update_team(
data=UpdateTeamRequest(team_id=_PATCH_TEAM_ID, **(payload or {})),
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
else:
if json_side_effect is not None:
req.json = AsyncMock(side_effect=json_side_effect)
else:
req.json = AsyncMock(
return_value=raw_body if raw_body is not None else dict(payload or {})
)
result = await patch_team(
team_id=_PATCH_TEAM_ID,
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
return result, pc.db.litellm_teamtable.update
async def _written_metadata(kind, existing_metadata, body):
_, update_mock = await _drive_team_write(kind, existing_metadata=existing_metadata, payload=body)
written = update_mock.call_args.kwargs["data"]
return written["metadata"] if "metadata" in written else _ABSENT
# (label, existing_metadata, merge_patch_body, expected_POST_metadata, expected_PATCH_metadata)
_METADATA_MAPPING = [
(
"omit-metadata-preserves-in-both",
{"cost_center": "1234"},
{"tpm_limit": 5},
_ABSENT, # POST: metadata column left untouched
_ABSENT, # PATCH: metadata column left untouched
),
(
"add-key-POST-wipes-others-PATCH-preserves",
{"cost_center": "1234", "foo": "bar"},
{"metadata": {"foo": "baz"}},
{"foo": "baz"}, # POST replaces wholesale -> cost_center wiped
{"cost_center": "1234", "foo": "baz"}, # PATCH merges -> cost_center kept
),
(
"overwrite-plus-null-delete-plus-add",
{"cost_center": "1234", "foo": "bar"},
{"metadata": {"cost_center": "9999", "foo": None, "new": "x"}},
{"cost_center": "9999", "foo": None, "new": "x"}, # POST stores the literal null
{"cost_center": "9999", "new": "x"}, # PATCH deletes foo via null
),
(
"null-delete-one-key",
{"a": 1, "b": 2},
{"metadata": {"b": None}},
{"b": None}, # POST wholesale replace -> only b:null survives
{"a": 1}, # PATCH deletes b, preserves a
),
(
"nested-object-deep-merge",
{"settings": {"x": 1, "y": 2}},
{"metadata": {"settings": {"y": 3, "z": 4}}},
{"settings": {"y": 3, "z": 4}}, # POST replaces the nested object wholesale
{"settings": {"x": 1, "y": 3, "z": 4}}, # PATCH deep-merges the nested object
),
(
"nested-object-null-delete",
{"settings": {"x": 1, "y": 2}},
{"metadata": {"settings": {"x": None}}},
{"settings": {"x": None}}, # POST wholesale
{"settings": {"y": 2}}, # PATCH deletes nested key, keeps sibling
),
(
"empty-object-POST-clears-PATCH-noops",
{"a": 1},
{"metadata": {}},
{}, # POST replaces with an empty object
{"a": 1}, # PATCH: an empty patch is a no-op
),
(
"metadata-null-clears-in-both",
{"a": 1},
{"metadata": None},
None, # POST clears the column
None, # PATCH: an RFC 7386 null patch clears the column too (parity)
),
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"label, existing_metadata, body, expected_post, expected_patch",
_METADATA_MAPPING,
ids=[row[0] for row in _METADATA_MAPPING],
)
async def test_post_vs_patch_metadata_write_mapping(
label, existing_metadata, body, expected_post, expected_patch
):
"""Exhaustive map: POST replaces metadata wholesale, PATCH merges per RFC 7386."""
post_meta = await _written_metadata("post", existing_metadata, body)
patch_meta = await _written_metadata("patch", existing_metadata, body)
assert post_meta == expected_post, f"POST metadata mismatch for '{label}'"
assert patch_meta == expected_patch, f"PATCH metadata mismatch for '{label}'"
@pytest.mark.asyncio
async def test_patch_preserves_required_metadata_key_that_post_would_wipe():
"""The reason PATCH exists: editing one metadata key must not silently drop
the others, which POST /team/update does because it replaces wholesale."""
existing = {"cost_center": "FINOPS-1", "team_notes": "keep me"}
body = {"metadata": {"team_notes": "edited"}}
post_meta = await _written_metadata("post", existing, body)
patch_meta = await _written_metadata("patch", existing, body)
assert post_meta == {"team_notes": "edited"}
assert "cost_center" not in post_meta # wiped by POST
assert patch_meta == {"cost_center": "FINOPS-1", "team_notes": "edited"} # preserved by PATCH
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body, field, expected",
[
({"tpm_limit": 50}, "tpm_limit", 50),
({"tpm_limit": None}, "tpm_limit", None),
({"models": ["gpt-4", "claude-3"]}, "models", ["gpt-4", "claude-3"]),
({"blocked": True}, "blocked", True),
({"max_budget": 10.0}, "max_budget", 10.0),
],
)
async def test_top_level_fields_identical_post_and_patch(body, field, expected):
"""Non-metadata fields are unaffected by merge semantics: value overwrites in both,
and neither touches metadata when the patch omits it."""
_, post_update = await _drive_team_write("post", existing_metadata={"k": "v"}, payload=body)
_, patch_update = await _drive_team_write("patch", existing_metadata={"k": "v"}, payload=body)
post_written = post_update.call_args.kwargs["data"]
patch_written = patch_update.call_args.kwargs["data"]
assert post_written[field] == expected
assert patch_written[field] == expected
assert "metadata" not in post_written
assert "metadata" not in patch_written
@pytest.mark.asyncio
async def test_patch_strips_system_managed_metadata_key_like_post():
"""A caller cannot inject/overwrite server-owned keys via PATCH any more than
via POST: team_member_budget_id is stripped from the write in both."""
existing = {"team_member_budget_id": "budget-123", "cost_center": "1234"}
body = {"metadata": {"team_member_budget_id": "HACKED", "cost_center": "9999"}}
post_meta = await _written_metadata("post", existing, body)
patch_meta = await _written_metadata("patch", existing, body)
assert "team_member_budget_id" not in post_meta
assert "team_member_budget_id" not in patch_meta
assert post_meta == {"cost_center": "9999"}
assert patch_meta == {"cost_center": "9999"}
@pytest.mark.asyncio
@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True])
async def test_patch_rejects_non_object_body(raw_body):
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_rejects_invalid_json_body():
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body")
)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_rejects_team_id_mismatch_between_path_and_body():
from litellm.proxy._types import ProxyException
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch",
existing_metadata={"a": 1},
raw_body={"team_id": "some-other-team", "tpm_limit": 5},
)
assert exc.value.code == "400" or exc.value.code == 400
@pytest.mark.asyncio
async def test_patch_accepts_matching_team_id_in_body():
"""A body team_id equal to the path is tolerated and does not leak into the write."""
_, update_mock = await _drive_team_write(
"patch",
existing_metadata={"a": 1},
raw_body={"team_id": _PATCH_TEAM_ID, "tpm_limit": 7},
)
written = update_mock.call_args.kwargs["data"]
assert written["tpm_limit"] == 7
@pytest.mark.asyncio
async def test_patch_team_not_found_returns_404():
from litellm.proxy._types import ProxyException
# metadata present -> patch_team does its own existence check
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", raw_body={"metadata": {"cost_center": "1"}}, find_returns_none=True
)
assert exc.value.code == "404" or exc.value.code == 404
# metadata absent -> existence check happens in the delegated update_team
with pytest.raises(ProxyException) as exc2:
await _drive_team_write("patch", raw_body={"tpm_limit": 5}, find_returns_none=True)
assert exc2.value.code == "404" or exc2.value.code == 404
@pytest.mark.asyncio
async def test_patch_enforces_team_access_via_delegation():
"""PATCH inherits POST's team-level RBAC: a caller who is neither proxy admin,
team admin, nor org admin of the team is rejected."""
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
outsider = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="outsider")
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", raw_body={"tpm_limit": 5}, user=outsider
)
assert exc.value.code == "403" or exc.value.code == 403
@pytest.mark.asyncio
async def test_patch_returns_full_team_object_not_wrapper():
"""Per REST convention the PATCH response is the full team, not POST's
{"team_id", "data"} envelope."""
from litellm.proxy._types import LiteLLM_TeamTable
result, _ = await _drive_team_write(
"patch", existing_metadata={"a": 1}, raw_body={"metadata": {"b": 2}}
)
assert isinstance(result, LiteLLM_TeamTable)
assert result.team_id == _PATCH_TEAM_ID

View file

@ -13842,6 +13842,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/team/{team_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch Team
* @description Partially update a team using RFC 7386 JSON Merge Patch semantics.
*
* `team_id` is taken from the path. `metadata` is merged with the team's stored
* metadata rather than replacing it: an omitted key is preserved, `key: null`
* deletes it, and any other value overwrites (recursing into nested objects).
* Every other field behaves exactly like `POST /team/update` (omitted preserves,
* a value overwrites). Returns the full updated team.
*
* ```
* curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{
* "metadata": {"cost_center": "1234", "deprecated_key": null}
* }'
* ```
*/
patch: operations["patch_team_team__team_id__patch"];
trace?: never;
};
"/team/{team_id}/callback": {
parameters: {
query?: never;
@ -50478,6 +50510,40 @@ export interface operations {
};
};
};
patch_team_team__team_id__patch: {
parameters: {
query?: never;
header?: {
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
"litellm-changed-by"?: string | null;
};
path: {
team_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LiteLLM_TeamTable"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_team_callbacks_team__team_id__callback_get: {
parameters: {
query?: never;