mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_complexity_router_keyword_tiers
This commit is contained in:
commit
fdaee89702
13 changed files with 1283 additions and 126 deletions
|
|
@ -589,6 +589,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# team
|
||||
"/team/new",
|
||||
"/team/update",
|
||||
"/team/{team_id}",
|
||||
"/team/delete",
|
||||
"/team/list",
|
||||
"/v2/team/list",
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ from .auth_checks_organization import (
|
|||
add_team_org_context_to_request_body,
|
||||
organization_role_based_access_check,
|
||||
)
|
||||
from .auth_utils import get_model_from_request
|
||||
from .auth_utils import get_model_from_request, get_request_route_template
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
|
|
@ -726,6 +726,7 @@ async def common_checks(
|
|||
route=route,
|
||||
request_body=request_body,
|
||||
fetch_team_org_id=_fetch_team_org_id,
|
||||
route_template=get_request_route_template(request),
|
||||
)
|
||||
|
||||
_is_route_allowed = _is_api_route_allowed(
|
||||
|
|
|
|||
|
|
@ -173,12 +173,17 @@ def _user_is_org_admin(
|
|||
|
||||
|
||||
TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"})
|
||||
# The RESTful update route carries the team id in the path. Match on the route
|
||||
# template so the sibling /team/<verb> routes (which share the single-segment
|
||||
# shape) are not mistaken for it and don't trigger a team lookup.
|
||||
PATCH_TEAM_ROUTE_TEMPLATE = "/team/{team_id}"
|
||||
|
||||
|
||||
async def add_team_org_context_to_request_body(
|
||||
route: str,
|
||||
request_body: dict,
|
||||
fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]],
|
||||
route_template: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Return a copy of request_body with organization_id resolved from the target
|
||||
|
|
@ -188,12 +193,20 @@ async def add_team_org_context_to_request_body(
|
|||
the client having to send it. Returns request_body unchanged when it does
|
||||
not apply, so callers that already pass organization_id and non-team routes
|
||||
are untouched.
|
||||
|
||||
The team_id is taken from the body for TEAM_ORG_CONTEXT_ROUTES, or from the
|
||||
last path segment when ``route_template`` is the ``/team/{team_id}`` route.
|
||||
"""
|
||||
if route not in TEAM_ORG_CONTEXT_ROUTES:
|
||||
return request_body
|
||||
if request_body.get("organization_id"):
|
||||
return request_body
|
||||
team_id = request_body.get("team_id")
|
||||
|
||||
if route in TEAM_ORG_CONTEXT_ROUTES:
|
||||
team_id: Optional[str] = request_body.get("team_id")
|
||||
elif route_template == PATCH_TEAM_ROUTE_TEMPLATE:
|
||||
team_id = route.rsplit("/", 1)[-1]
|
||||
else:
|
||||
return request_body
|
||||
|
||||
if not isinstance(team_id, str) or not team_id:
|
||||
return request_body
|
||||
org_id = await fetch_team_org_id(team_id)
|
||||
|
|
|
|||
33
litellm/proxy/common_utils/json_merge_patch.py
Normal file
33
litellm/proxy/common_utils/json_merge_patch.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""RFC 7386 JSON Merge Patch (https://www.rfc-editor.org/rfc/rfc7386)."""
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
# A merge patch recurses as deep as the client's JSON nests. Cap it far above any
|
||||
# realistic team-metadata shape but well below Python's stack limit, so a
|
||||
# pathologically deep patch is rejected instead of overflowing the stack.
|
||||
_MAX_MERGE_DEPTH = 64
|
||||
|
||||
|
||||
def apply_json_merge_patch(target: JsonValue, patch: JsonValue, _depth: int = 0) -> JsonValue:
|
||||
"""Apply an RFC 7386 JSON Merge Patch to ``target`` and return the result.
|
||||
|
||||
- a key absent from ``patch`` keeps its value in ``target``
|
||||
- a key mapped to ``null`` in ``patch`` is removed from the result
|
||||
- any other value overwrites, recursing into nested objects
|
||||
|
||||
``target`` is never mutated; a new value is returned. Raises ``ValueError``
|
||||
if ``patch`` nests deeper than ``_MAX_MERGE_DEPTH``.
|
||||
"""
|
||||
if not isinstance(patch, dict):
|
||||
return patch
|
||||
if _depth >= _MAX_MERGE_DEPTH:
|
||||
raise ValueError(f"JSON merge patch nesting exceeds the maximum depth of {_MAX_MERGE_DEPTH}")
|
||||
|
||||
base = target if isinstance(target, dict) else {}
|
||||
preserved = {key: value for key, value in base.items() if key not in patch}
|
||||
applied = {
|
||||
key: apply_json_merge_patch(base.get(key), value, _depth + 1)
|
||||
for key, value in patch.items()
|
||||
if value is not None
|
||||
}
|
||||
return {**preserved, **applied}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ IGNORE_FUNCTIONS = [
|
|||
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
|
||||
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
|
||||
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
|
||||
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2724,6 +2724,164 @@ def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
|
|||
)
|
||||
|
||||
|
||||
# ── PATCH /team/{team_id}: same org-context + role reach as POST /team/update ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_team_org_context_resolves_org_from_path_for_patch_route():
|
||||
"""PATCH /team/{team_id} carries team_id in the PATH, not the body. The target
|
||||
team's org is resolved from the last path segment (identified by the route
|
||||
template) and injected, so an org admin of that team's org clears the same gate
|
||||
they clear for POST /team/update."""
|
||||
|
||||
async def fetch(team_id: str):
|
||||
assert team_id == "team-1"
|
||||
return "org-1"
|
||||
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/team-1",
|
||||
request_body={"metadata": {"cost_center": "x"}},
|
||||
fetch_team_org_id=fetch,
|
||||
route_template="/team/{team_id}",
|
||||
)
|
||||
assert out == {"metadata": {"cost_center": "x"}, "organization_id": "org-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_team_org_context_path_noop_for_team_subresource():
|
||||
"""A sub-resource like /team/{team_id}/members/me has a different route template,
|
||||
so it is not mistaken for the bare team route and no org is injected."""
|
||||
|
||||
async def fetch(team_id: str):
|
||||
raise AssertionError("must not resolve for a team sub-resource route")
|
||||
|
||||
body = {"foo": "bar"}
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/team-1/members/me",
|
||||
request_body=body,
|
||||
fetch_team_org_id=fetch,
|
||||
route_template="/team/{team_id}/members/me",
|
||||
)
|
||||
assert out == body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_team_org_context_noop_for_static_team_route():
|
||||
"""A static sibling route (e.g. POST /team/new) whose resolved path also has the
|
||||
single-segment shape has its own template, not /team/{team_id}, so no team lookup
|
||||
is attempted — the guard against a spurious DB hit on every /team/<verb> call."""
|
||||
|
||||
async def fetch(team_id: str):
|
||||
raise AssertionError("must not resolve for a static /team/<verb> route")
|
||||
|
||||
body = {"team_alias": "new team"}
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/new",
|
||||
request_body=body,
|
||||
fetch_team_org_id=fetch,
|
||||
route_template="/team/new",
|
||||
)
|
||||
assert out == body
|
||||
|
||||
|
||||
def test_patch_team_route_has_same_reach_as_team_update():
|
||||
"""/team/{team_id} is reachable by org admins (in org_admin_allowed_routes) but
|
||||
NOT by regular internal users or the role-agnostic self_managed_routes — the
|
||||
latter would open /team/new (the collision footgun) to any authenticated user."""
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert RouteChecks.check_route_access(
|
||||
route="/team/abc-123", allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
|
||||
)
|
||||
assert not RouteChecks.check_route_access(
|
||||
route="/team/abc-123", allowed_routes=LiteLLMRoutes.internal_user_routes.value
|
||||
)
|
||||
assert not RouteChecks.check_route_access(
|
||||
route="/team/abc-123", allowed_routes=LiteLLMRoutes.self_managed_routes.value
|
||||
)
|
||||
|
||||
|
||||
def _patch_team_request() -> MagicMock:
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = "PATCH"
|
||||
request.query_params = {}
|
||||
return request
|
||||
|
||||
|
||||
def test_patch_team_gate_allows_org_admin_with_resolved_org():
|
||||
"""Post-resolution, an org admin of the team's org clears the coarse gate for
|
||||
PATCH /team/{team_id} — parity with /team/update."""
|
||||
user_obj = _make_org_admin_user("org-1")
|
||||
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route="/team/team-1",
|
||||
request=_patch_team_request(),
|
||||
valid_token=valid_token,
|
||||
request_data={"organization_id": "org-1"},
|
||||
)
|
||||
|
||||
|
||||
def test_patch_team_gate_rejects_regular_internal_user():
|
||||
"""A plain internal user (not an org admin) is rejected at the coarse gate for
|
||||
PATCH /team/{team_id}, even with the team's org resolved — injection alone is
|
||||
not access. Same outcome as /team/update."""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="regular-user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
organization_memberships=None,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="regular-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route="/team/team-1",
|
||||
request=_patch_team_request(),
|
||||
valid_token=valid_token,
|
||||
request_data={"organization_id": "org-1"},
|
||||
)
|
||||
|
||||
|
||||
def test_patch_team_gate_rejects_cross_org_admin():
|
||||
"""An org admin of a DIFFERENT org is rejected even after org resolution."""
|
||||
user_obj = _make_org_admin_user("org-1")
|
||||
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route="/team/team-1",
|
||||
request=_patch_team_request(),
|
||||
valid_token=valid_token,
|
||||
request_data={"organization_id": "org-2"},
|
||||
)
|
||||
|
||||
|
||||
def test_patch_team_gate_rejects_view_only_admin():
|
||||
"""A view-only proxy admin cannot PATCH a team (unsafe method), parity with the
|
||||
/team/update view-only block."""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="viewer",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
route="/team/team-1",
|
||||
request=_patch_team_request(),
|
||||
valid_token=valid_token,
|
||||
request_data={"organization_id": "org-1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.common_utils.json_merge_patch import _MAX_MERGE_DEPTH, apply_json_merge_patch
|
||||
|
||||
# RFC 7386 Appendix A — the normative test suite for JSON Merge Patch.
|
||||
# https://www.rfc-editor.org/rfc/rfc7386#appendix-A
|
||||
RFC_7386_APPENDIX_A = [
|
||||
({"a": "b"}, {"a": "c"}, {"a": "c"}),
|
||||
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}),
|
||||
({"a": "b"}, {"a": None}, {}),
|
||||
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}),
|
||||
({"a": ["b"]}, {"a": "c"}, {"a": "c"}),
|
||||
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}),
|
||||
({"a": {"b": "c"}}, {"a": {"b": "d", "c": None}}, {"a": {"b": "d"}}),
|
||||
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}),
|
||||
(["a", "b"], ["c", "d"], ["c", "d"]),
|
||||
({"a": "b"}, ["c"], ["c"]),
|
||||
({"a": "foo"}, None, None),
|
||||
({"a": "foo"}, "bar", "bar"),
|
||||
({"e": None}, {"a": 1}, {"e": None, "a": 1}),
|
||||
([1, 2], {"a": "b", "c": None}, {"a": "b"}),
|
||||
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target, patch, expected", RFC_7386_APPENDIX_A)
|
||||
def test_rfc_7386_appendix_a(target, patch, expected):
|
||||
assert apply_json_merge_patch(target, patch) == expected
|
||||
|
||||
|
||||
def test_does_not_mutate_target():
|
||||
"""The target must be treated as immutable — a fresh value is returned."""
|
||||
target = {"keep": "me", "nested": {"a": 1, "b": 2}, "drop": "later"}
|
||||
target_snapshot = copy.deepcopy(target)
|
||||
|
||||
result = apply_json_merge_patch(target, {"nested": {"b": None, "c": 3}, "drop": None})
|
||||
|
||||
assert target == target_snapshot, "apply_json_merge_patch mutated its target argument"
|
||||
assert result == {"keep": "me", "nested": {"a": 1, "c": 3}}
|
||||
assert result["nested"] is not target["nested"]
|
||||
|
||||
|
||||
def test_absent_key_is_preserved_but_null_key_is_deleted():
|
||||
"""The core distinction PATCH relies on: omission preserves, explicit null deletes."""
|
||||
target = {"cost_center": "1234", "team": "core"}
|
||||
|
||||
# Omitting cost_center preserves it; only the explicitly-null key is removed.
|
||||
assert apply_json_merge_patch(target, {"team": "platform"}) == {
|
||||
"cost_center": "1234",
|
||||
"team": "platform",
|
||||
}
|
||||
assert apply_json_merge_patch(target, {"cost_center": None}) == {"team": "core"}
|
||||
|
||||
|
||||
def test_deep_nested_merge_and_delete():
|
||||
target = {"limits": {"gpt-4": {"rpm": 100, "tpm": 1000}, "gpt-3.5": {"rpm": 200}}}
|
||||
patch = {"limits": {"gpt-4": {"tpm": 2000}, "gpt-3.5": None, "claude": {"rpm": 50}}}
|
||||
|
||||
assert apply_json_merge_patch(target, patch) == {
|
||||
"limits": {"gpt-4": {"rpm": 100, "tpm": 2000}, "claude": {"rpm": 50}}
|
||||
}
|
||||
|
||||
|
||||
def test_scalar_patch_replaces_object_wholesale():
|
||||
assert apply_json_merge_patch({"a": {"b": 1}}, 5) == 5
|
||||
|
||||
|
||||
def test_object_patch_over_non_object_target_starts_from_empty():
|
||||
assert apply_json_merge_patch("not-an-object", {"a": 1, "b": None}) == {"a": 1}
|
||||
|
||||
|
||||
def _nest(levels: int) -> dict:
|
||||
"""A patch nested ``levels`` dicts deep with a scalar leaf at the bottom."""
|
||||
value: object = "leaf"
|
||||
for _ in range(levels):
|
||||
value = {"a": value}
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_merge_within_max_depth_is_allowed():
|
||||
"""A deeply-but-not-pathologically nested patch merges without raising."""
|
||||
result = apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH - 1))
|
||||
for _ in range(_MAX_MERGE_DEPTH - 1):
|
||||
result = result["a"]
|
||||
assert result == "leaf"
|
||||
|
||||
|
||||
def test_merge_beyond_max_depth_raises():
|
||||
"""A patch nested past the cap fails closed (ValueError) rather than
|
||||
overflowing the Python stack — the guard the recursion detector requires."""
|
||||
with pytest.raises(ValueError, match="maximum depth"):
|
||||
apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH + 5))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9535,3 +9535,332 @@ async def test_new_team_rejects_reserved_ui_session_team_id():
|
|||
assert exc_info.value.code == "400"
|
||||
assert "reserved" in str(exc_info.value.message)
|
||||
mock_prisma.get_data.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /team/{team_id} — RFC 7386 JSON Merge Patch
|
||||
#
|
||||
# The new PATCH endpoint delegates to the same write path as POST /team/update;
|
||||
# the single intended divergence is metadata. POST replaces the metadata column
|
||||
# wholesale, PATCH merges it per RFC 7386 (omit preserves, null deletes, value
|
||||
# overwrites, recursing into nested objects). Every other field must behave
|
||||
# identically. Each test drives BOTH endpoints against an identical mocked team
|
||||
# and asserts on the exact dict handed to litellm_teamtable.update.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATCH_TEAM_ID = "team-merge-patch-test"
|
||||
_ABSENT = object()
|
||||
|
||||
|
||||
async def _drive_team_write(
|
||||
kind,
|
||||
*,
|
||||
existing_metadata=None,
|
||||
existing_kwargs=None,
|
||||
payload=None,
|
||||
raw_body=None,
|
||||
user=None,
|
||||
find_returns_none=False,
|
||||
json_side_effect=None,
|
||||
):
|
||||
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
|
||||
|
||||
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
|
||||
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
UpdateTeamRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
patch_team,
|
||||
update_team,
|
||||
)
|
||||
|
||||
existing = LiteLLM_TeamTable(
|
||||
team_id=_PATCH_TEAM_ID,
|
||||
team_alias="t",
|
||||
metadata=existing_metadata,
|
||||
organization_id=None,
|
||||
**(existing_kwargs or {}),
|
||||
)
|
||||
auth = user or UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u")
|
||||
|
||||
with (
|
||||
_patch("litellm.proxy.proxy_server.prisma_client") as pc,
|
||||
_patch("litellm.proxy.proxy_server.llm_router", None),
|
||||
_patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
_patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
|
||||
_patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
_patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
pc.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=None if find_returns_none else existing
|
||||
)
|
||||
pc.db.litellm_teamtable.update = AsyncMock(
|
||||
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
|
||||
)
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
|
||||
req = Mock(spec=Request)
|
||||
if kind == "post":
|
||||
result = await update_team(
|
||||
data=UpdateTeamRequest(team_id=_PATCH_TEAM_ID, **(payload or {})),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
else:
|
||||
if json_side_effect is not None:
|
||||
req.json = AsyncMock(side_effect=json_side_effect)
|
||||
else:
|
||||
req.json = AsyncMock(
|
||||
return_value=raw_body if raw_body is not None else dict(payload or {})
|
||||
)
|
||||
result = await patch_team(
|
||||
team_id=_PATCH_TEAM_ID,
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
return result, pc.db.litellm_teamtable.update
|
||||
|
||||
|
||||
async def _written_metadata(kind, existing_metadata, body):
|
||||
_, update_mock = await _drive_team_write(kind, existing_metadata=existing_metadata, payload=body)
|
||||
written = update_mock.call_args.kwargs["data"]
|
||||
return written["metadata"] if "metadata" in written else _ABSENT
|
||||
|
||||
|
||||
# (label, existing_metadata, merge_patch_body, expected_POST_metadata, expected_PATCH_metadata)
|
||||
_METADATA_MAPPING = [
|
||||
(
|
||||
"omit-metadata-preserves-in-both",
|
||||
{"cost_center": "1234"},
|
||||
{"tpm_limit": 5},
|
||||
_ABSENT, # POST: metadata column left untouched
|
||||
_ABSENT, # PATCH: metadata column left untouched
|
||||
),
|
||||
(
|
||||
"add-key-POST-wipes-others-PATCH-preserves",
|
||||
{"cost_center": "1234", "foo": "bar"},
|
||||
{"metadata": {"foo": "baz"}},
|
||||
{"foo": "baz"}, # POST replaces wholesale -> cost_center wiped
|
||||
{"cost_center": "1234", "foo": "baz"}, # PATCH merges -> cost_center kept
|
||||
),
|
||||
(
|
||||
"overwrite-plus-null-delete-plus-add",
|
||||
{"cost_center": "1234", "foo": "bar"},
|
||||
{"metadata": {"cost_center": "9999", "foo": None, "new": "x"}},
|
||||
{"cost_center": "9999", "foo": None, "new": "x"}, # POST stores the literal null
|
||||
{"cost_center": "9999", "new": "x"}, # PATCH deletes foo via null
|
||||
),
|
||||
(
|
||||
"null-delete-one-key",
|
||||
{"a": 1, "b": 2},
|
||||
{"metadata": {"b": None}},
|
||||
{"b": None}, # POST wholesale replace -> only b:null survives
|
||||
{"a": 1}, # PATCH deletes b, preserves a
|
||||
),
|
||||
(
|
||||
"nested-object-deep-merge",
|
||||
{"settings": {"x": 1, "y": 2}},
|
||||
{"metadata": {"settings": {"y": 3, "z": 4}}},
|
||||
{"settings": {"y": 3, "z": 4}}, # POST replaces the nested object wholesale
|
||||
{"settings": {"x": 1, "y": 3, "z": 4}}, # PATCH deep-merges the nested object
|
||||
),
|
||||
(
|
||||
"nested-object-null-delete",
|
||||
{"settings": {"x": 1, "y": 2}},
|
||||
{"metadata": {"settings": {"x": None}}},
|
||||
{"settings": {"x": None}}, # POST wholesale
|
||||
{"settings": {"y": 2}}, # PATCH deletes nested key, keeps sibling
|
||||
),
|
||||
(
|
||||
"empty-object-POST-clears-PATCH-noops",
|
||||
{"a": 1},
|
||||
{"metadata": {}},
|
||||
{}, # POST replaces with an empty object
|
||||
{"a": 1}, # PATCH: an empty patch is a no-op
|
||||
),
|
||||
(
|
||||
"metadata-null-clears-in-both",
|
||||
{"a": 1},
|
||||
{"metadata": None},
|
||||
None, # POST clears the column
|
||||
None, # PATCH: an RFC 7386 null patch clears the column too (parity)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"label, existing_metadata, body, expected_post, expected_patch",
|
||||
_METADATA_MAPPING,
|
||||
ids=[row[0] for row in _METADATA_MAPPING],
|
||||
)
|
||||
async def test_post_vs_patch_metadata_write_mapping(
|
||||
label, existing_metadata, body, expected_post, expected_patch
|
||||
):
|
||||
"""Exhaustive map: POST replaces metadata wholesale, PATCH merges per RFC 7386."""
|
||||
post_meta = await _written_metadata("post", existing_metadata, body)
|
||||
patch_meta = await _written_metadata("patch", existing_metadata, body)
|
||||
|
||||
assert post_meta == expected_post, f"POST metadata mismatch for '{label}'"
|
||||
assert patch_meta == expected_patch, f"PATCH metadata mismatch for '{label}'"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_preserves_required_metadata_key_that_post_would_wipe():
|
||||
"""The reason PATCH exists: editing one metadata key must not silently drop
|
||||
the others, which POST /team/update does because it replaces wholesale."""
|
||||
existing = {"cost_center": "FINOPS-1", "team_notes": "keep me"}
|
||||
body = {"metadata": {"team_notes": "edited"}}
|
||||
|
||||
post_meta = await _written_metadata("post", existing, body)
|
||||
patch_meta = await _written_metadata("patch", existing, body)
|
||||
|
||||
assert post_meta == {"team_notes": "edited"}
|
||||
assert "cost_center" not in post_meta # wiped by POST
|
||||
assert patch_meta == {"cost_center": "FINOPS-1", "team_notes": "edited"} # preserved by PATCH
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"body, field, expected",
|
||||
[
|
||||
({"tpm_limit": 50}, "tpm_limit", 50),
|
||||
({"tpm_limit": None}, "tpm_limit", None),
|
||||
({"models": ["gpt-4", "claude-3"]}, "models", ["gpt-4", "claude-3"]),
|
||||
({"blocked": True}, "blocked", True),
|
||||
({"max_budget": 10.0}, "max_budget", 10.0),
|
||||
],
|
||||
)
|
||||
async def test_top_level_fields_identical_post_and_patch(body, field, expected):
|
||||
"""Non-metadata fields are unaffected by merge semantics: value overwrites in both,
|
||||
and neither touches metadata when the patch omits it."""
|
||||
_, post_update = await _drive_team_write("post", existing_metadata={"k": "v"}, payload=body)
|
||||
_, patch_update = await _drive_team_write("patch", existing_metadata={"k": "v"}, payload=body)
|
||||
post_written = post_update.call_args.kwargs["data"]
|
||||
patch_written = patch_update.call_args.kwargs["data"]
|
||||
|
||||
assert post_written[field] == expected
|
||||
assert patch_written[field] == expected
|
||||
assert "metadata" not in post_written
|
||||
assert "metadata" not in patch_written
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_strips_system_managed_metadata_key_like_post():
|
||||
"""A caller cannot inject/overwrite server-owned keys via PATCH any more than
|
||||
via POST: team_member_budget_id is stripped from the write in both."""
|
||||
existing = {"team_member_budget_id": "budget-123", "cost_center": "1234"}
|
||||
body = {"metadata": {"team_member_budget_id": "HACKED", "cost_center": "9999"}}
|
||||
|
||||
post_meta = await _written_metadata("post", existing, body)
|
||||
patch_meta = await _written_metadata("patch", existing, body)
|
||||
|
||||
assert "team_member_budget_id" not in post_meta
|
||||
assert "team_member_budget_id" not in patch_meta
|
||||
assert post_meta == {"cost_center": "9999"}
|
||||
assert patch_meta == {"cost_center": "9999"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True])
|
||||
async def test_patch_rejects_non_object_body(raw_body):
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body)
|
||||
assert exc.value.code == "400" or exc.value.code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_rejects_invalid_json_body():
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body")
|
||||
)
|
||||
assert exc.value.code == "400" or exc.value.code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_rejects_team_id_mismatch_between_path_and_body():
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"a": 1},
|
||||
raw_body={"team_id": "some-other-team", "tpm_limit": 5},
|
||||
)
|
||||
assert exc.value.code == "400" or exc.value.code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_accepts_matching_team_id_in_body():
|
||||
"""A body team_id equal to the path is tolerated and does not leak into the write."""
|
||||
_, update_mock = await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"a": 1},
|
||||
raw_body={"team_id": _PATCH_TEAM_ID, "tpm_limit": 7},
|
||||
)
|
||||
written = update_mock.call_args.kwargs["data"]
|
||||
assert written["tpm_limit"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_team_not_found_returns_404():
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
# metadata present -> patch_team does its own existence check
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch", raw_body={"metadata": {"cost_center": "1"}}, find_returns_none=True
|
||||
)
|
||||
assert exc.value.code == "404" or exc.value.code == 404
|
||||
|
||||
# metadata absent -> existence check happens in the delegated update_team
|
||||
with pytest.raises(ProxyException) as exc2:
|
||||
await _drive_team_write("patch", raw_body={"tpm_limit": 5}, find_returns_none=True)
|
||||
assert exc2.value.code == "404" or exc2.value.code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_enforces_team_access_via_delegation():
|
||||
"""PATCH inherits POST's team-level RBAC: a caller who is neither proxy admin,
|
||||
team admin, nor org admin of the team is rejected."""
|
||||
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
|
||||
|
||||
outsider = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="outsider")
|
||||
with pytest.raises(ProxyException) as exc:
|
||||
await _drive_team_write(
|
||||
"patch", raw_body={"tpm_limit": 5}, user=outsider
|
||||
)
|
||||
assert exc.value.code == "403" or exc.value.code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_returns_full_team_object_not_wrapper():
|
||||
"""Per REST convention the PATCH response is the full team, not POST's
|
||||
{"team_id", "data"} envelope."""
|
||||
from litellm.proxy._types import LiteLLM_TeamTable
|
||||
|
||||
result, _ = await _drive_team_write(
|
||||
"patch", existing_metadata={"a": 1}, raw_body={"metadata": {"b": 2}}
|
||||
)
|
||||
assert isinstance(result, LiteLLM_TeamTable)
|
||||
assert result.team_id == _PATCH_TEAM_ID
|
||||
|
|
|
|||
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -13842,6 +13842,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/{team_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Patch Team
|
||||
* @description Partially update a team using RFC 7386 JSON Merge Patch semantics.
|
||||
*
|
||||
* `team_id` is taken from the path. `metadata` is merged with the team's stored
|
||||
* metadata rather than replacing it: an omitted key is preserved, `key: null`
|
||||
* deletes it, and any other value overwrites (recursing into nested objects).
|
||||
* Every other field behaves exactly like `POST /team/update` (omitted preserves,
|
||||
* a value overwrites). Returns the full updated team.
|
||||
*
|
||||
* ```
|
||||
* curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{
|
||||
* "metadata": {"cost_center": "1234", "deprecated_key": null}
|
||||
* }'
|
||||
* ```
|
||||
*/
|
||||
patch: operations["patch_team_team__team_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/team/{team_id}/callback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -50478,6 +50510,40 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
patch_team_team__team_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
|
||||
"litellm-changed-by"?: string | null;
|
||||
};
|
||||
path: {
|
||||
team_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LiteLLM_TeamTable"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_team_callbacks_team__team_id__callback_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue