mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
feat(team): add DELETE /team/{team_id}/callback/{callback_name}
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
b930e2fc2b
commit
8fb6689dca
3 changed files with 330 additions and 1 deletions
|
|
@ -9,7 +9,7 @@ import copy
|
|||
import json
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
from typing import Annotated, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
||||
|
|
@ -268,6 +268,106 @@ async def add_team_callbacks(
|
|||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/team/{team_id}/callback/{callback_name}",
|
||||
tags=["team management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def delete_team_callback(
|
||||
http_request: Request,
|
||||
team_id: str,
|
||||
callback_name: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
litellm_changed_by: Annotated[
|
||||
str | None,
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Delete a single logging callback from a team, leaving the team's other callbacks untouched
|
||||
|
||||
Parameters:
|
||||
- team_id (str, required): The unique identifier for the team
|
||||
- callback_name (str, required): The name of the callback to remove, as passed to `POST /team/{team_id}/callback` (e.g. "langsmith")
|
||||
|
||||
Every entry registered under `callback_name` is removed, regardless of its `callback_type`
|
||||
|
||||
Example curl:
|
||||
```
|
||||
curl -X DELETE 'http://localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/callback/langsmith' \
|
||||
-H 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
|
||||
Returns 404 if the team has no callback registered under that name
|
||||
"""
|
||||
from litellm.proxy._types import CommonProxyErrors
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
_existing_team = await prisma_client.get_data(team_id=team_id, table_name="team", query_type="find_unique")
|
||||
if _existing_team is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Team id = {team_id} does not exist."},
|
||||
)
|
||||
|
||||
await _verify_team_access(
|
||||
team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
team_metadata = _existing_team.metadata
|
||||
existing_callbacks = team_metadata.get("logging")
|
||||
if not isinstance(existing_callbacks, list):
|
||||
existing_callbacks = []
|
||||
|
||||
remaining_callbacks = tuple(
|
||||
callback
|
||||
for callback in existing_callbacks
|
||||
if not (isinstance(callback, dict) and callback.get("callback_name") == callback_name)
|
||||
)
|
||||
if len(remaining_callbacks) == len(existing_callbacks):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"callback_name = {callback_name} is not registered for team_id = {team_id}."},
|
||||
)
|
||||
|
||||
before_metadata = copy.deepcopy(team_metadata)
|
||||
updated_metadata = encrypt_callback_vars({**team_metadata, "logging": list(remaining_callbacks)})
|
||||
|
||||
await TeamRepository(prisma_client).table.update(
|
||||
where={"team_id": team_id},
|
||||
data={"metadata": json.dumps(updated_metadata)},
|
||||
)
|
||||
|
||||
await _emit_team_callback_audit_log(
|
||||
team_id=team_id,
|
||||
before_metadata=before_metadata,
|
||||
after_metadata=updated_metadata,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Callback {callback_name} deleted for team {team_id}",
|
||||
"data": {
|
||||
"team_id": team_id,
|
||||
"remaining_callback_names": [
|
||||
callback.get("callback_name") for callback in remaining_callbacks if isinstance(callback, dict)
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/team/{team_id}/disable_logging",
|
||||
tags=["team management"],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.proxy._types import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.team_callback_endpoints import (
|
||||
add_team_callbacks,
|
||||
delete_team_callback,
|
||||
disable_team_logging,
|
||||
get_team_callbacks,
|
||||
)
|
||||
|
|
@ -459,3 +460,163 @@ async def test_add_team_callbacks_writes_encrypted_callback_vars(monkeypatch):
|
|||
recovered = decrypt_callback_vars(written)["logging"][0]["callback_vars"]
|
||||
assert recovered["langfuse_secret_key"] == "sk-lf-real-secret"
|
||||
assert recovered["langfuse_public_key"] == "pk-lf-real-public"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_callback_removes_only_named_callback(monkeypatch):
|
||||
"""delete_team_callback must drop every entry for one name and leave the rest intact."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-32-bytes-aaaaaaaaaaaaaa")
|
||||
mock_prisma = _patch_prisma(
|
||||
_team_row(
|
||||
team_id="team-1",
|
||||
metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langsmith",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {"langsmith_api_key": "ls-success"},
|
||||
},
|
||||
{
|
||||
"callback_name": "langsmith",
|
||||
"callback_type": "failure",
|
||||
"callback_vars": {"langsmith_api_key": "ls-failure"},
|
||||
},
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {"langfuse_secret_key": "sk-keep-me"},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
patch("litellm.proxy.proxy_server.master_key", None),
|
||||
):
|
||||
response = await delete_team_callback(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="team-1",
|
||||
callback_name="langsmith",
|
||||
user_api_key_dict=_admin_auth(),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
assert response["data"]["remaining_callback_names"] == ["langfuse"]
|
||||
|
||||
written = json.loads(
|
||||
mock_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"]
|
||||
)
|
||||
assert [entry["callback_name"] for entry in written["logging"]] == ["langfuse"]
|
||||
|
||||
from litellm.proxy.common_utils.callback_utils import decrypt_callback_vars
|
||||
|
||||
recovered = decrypt_callback_vars(written)["logging"][0]["callback_vars"]
|
||||
assert recovered["langfuse_secret_key"] == "sk-keep-me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_callback_unknown_name_returns_404():
|
||||
mock_prisma = _patch_prisma(
|
||||
_team_row(
|
||||
team_id="team-1",
|
||||
metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await delete_team_callback(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="team-1",
|
||||
callback_name="langsmith",
|
||||
user_api_key_dict=_admin_auth(),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
mock_prisma.db.litellm_teamtable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_callback_rejects_unauthorized_caller(
|
||||
patched_prisma, unauthorized_caller
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await delete_team_callback(
|
||||
http_request=Mock(spec=Request),
|
||||
team_id="team-victim",
|
||||
callback_name="langsmith",
|
||||
user_api_key_dict=unauthorized_caller,
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
patched_prisma.db.litellm_teamtable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_callback_audit_log_redacts_secrets(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", True)
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-32-bytes-aaaaaaaaaaaaaa")
|
||||
mock_prisma = _patch_prisma(
|
||||
_team_row(
|
||||
team_id="team-1",
|
||||
metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langsmith",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {"langsmith_api_key": "ls-secret"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
patch("litellm.proxy.proxy_server.master_key", None),
|
||||
patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
),
|
||||
):
|
||||
await delete_team_callback(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="team-1",
|
||||
callback_name="langsmith",
|
||||
user_api_key_dict=_admin_auth(),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(audit_calls) == 1
|
||||
log = audit_calls[0]
|
||||
assert log.table_name == LitellmTableNames.TEAM_TABLE_NAME
|
||||
assert log.object_id == "team-1"
|
||||
before = json.loads(log.before_value)
|
||||
after = json.loads(log.updated_values)
|
||||
assert "ls-secret" not in log.before_value
|
||||
assert "langsmith_api_key" in before["metadata"]["logging"][0]["callback_vars"]
|
||||
assert after["metadata"]["logging"] == []
|
||||
|
|
|
|||
68
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
68
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -14045,6 +14045,39 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/{team_id}/callback/{callback_name}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Team Callback
|
||||
* @description Delete a single logging callback from a team, leaving the team's other callbacks untouched
|
||||
*
|
||||
* Parameters:
|
||||
* - team_id (str, required): The unique identifier for the team
|
||||
* - callback_name (str, required): The name of the callback to remove, as passed to `POST /team/{team_id}/callback` (e.g. "langsmith")
|
||||
*
|
||||
* Every entry registered under `callback_name` is removed, regardless of its `callback_type`
|
||||
*
|
||||
* Example curl:
|
||||
* ```
|
||||
* curl -X DELETE 'http://localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/callback/langsmith' -H 'Authorization: Bearer sk-1234'
|
||||
* ```
|
||||
*
|
||||
* Returns 404 if the team has no callback registered under that name
|
||||
*/
|
||||
delete: operations["delete_team_callback_team__team_id__callback__callback_name__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/{team_id}/disable_logging": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -51213,6 +51246,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
delete_team_callback_team__team_id__callback__callback_name__delete: {
|
||||
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;
|
||||
callback_name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
disable_team_logging_team__team_id__disable_logging_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue