From 8fb6689dca0c2f451dbc4a1eadc28fbce914f693 Mon Sep 17 00:00:00 2001 From: milan Date: Tue, 28 Jul 2026 18:06:51 +0000 Subject: [PATCH] feat(team): add DELETE /team/{team_id}/callback/{callback_name} Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../team_callback_endpoints.py | 102 ++++++++++- .../test_team_callback_endpoints.py | 161 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 68 ++++++++ 3 files changed, 330 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 025b7c4210e..ca41fb673c2 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -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"], diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index d43bf3a3bd8..dc93e09b031 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -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"] == [] diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 541d7a17ae1..9727808c278 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;