diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 025b7c4210e..b4d53e35bfe 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -8,7 +8,9 @@ import asyncio import copy import json import traceback +from collections.abc import Mapping from datetime import datetime, timezone +from functools import reduce from typing import Any, List, Optional from fastapi import APIRouter, Depends, Header, HTTPException, Request, status @@ -27,7 +29,15 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) 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.callback_utils import ( + decrypt_callback_vars, + encrypt_callback_vars, + is_sensitive_callback_key, +) +from litellm.proxy.litellm_pre_call_utils import ( + _get_validated_callback_metadata, + convert_key_logging_metadata_to_callback, +) from litellm.proxy.management_endpoints.team_endpoints import _verify_team_access from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.repositories.team_repository import TeamRepository @@ -64,6 +74,56 @@ def _redact_callback_secrets(metadata: Any) -> Any: return redacted +def _resolve_team_callbacks(team_metadata: object) -> TeamCallbackMetadata: + """Collapse a team's stored callback config into a single view. + + Team callbacks live in two different metadata slots: ``logging`` holds + the ``AddTeamCallback`` entries written by ``POST /team/{id}/callback`` + (and is what request-time resolution in ``litellm_pre_call_utils`` and + the Admin UI read), while ``callback_settings`` holds the older + ``TeamCallbackMetadata`` shape used by config-driven setups and + ``disable_team_logging``. Reading only one of them reports nothing for + teams configured through the other. + + Credential-bearing ``callback_vars`` are stored encrypted; they are + decrypted here only so the sensitive ones can be reported as redacted + rather than as opaque ciphertext, while non-secret vars (project names, + bucket names, hosts) come back usable. + """ + if not isinstance(team_metadata, dict): + return TeamCallbackMetadata() + decrypted = decrypt_callback_vars(team_metadata) + callback_settings = decrypted.get("callback_settings") + base = TeamCallbackMetadata(**callback_settings) if isinstance(callback_settings, dict) else TeamCallbackMetadata() + logging_entries = decrypted.get("logging") + callbacks = ( + tuple( + callback + for entry in logging_entries + if isinstance(entry, dict) + for callback in (_get_validated_callback_metadata(item=entry, source="team-level"),) + if callback is not None + ) + if isinstance(logging_entries, list) + else () + ) + resolved = reduce( + lambda acc, callback: convert_key_logging_metadata_to_callback(data=callback, team_callback_settings_obj=acc), + callbacks, + base, + ) + return resolved.model_copy(update={"callback_vars": _redact_sensitive_callback_vars(resolved.callback_vars)}) + + +def _redact_sensitive_callback_vars(callback_vars: Mapping[str, str] | None) -> Mapping[str, str]: + if not callback_vars: + return {} + return { + key: (_CALLBACK_VARS_REDACTED if is_sensitive_callback_key(key) else value) + for key, value in callback_vars.items() + } + + def _log_audit_task_exception(task: "asyncio.Task[None]") -> None: """Surface a fire-and-forget audit-log task failure. @@ -331,6 +391,8 @@ async def disable_team_logging( # Update metadata team_metadata["callback_settings"] = team_callback_settings_obj.model_dump() + if "logging" in team_metadata: + team_metadata["logging"] = [] team_metadata = encrypt_callback_vars(team_metadata) team_metadata_json = json.dumps(team_metadata) @@ -442,12 +504,7 @@ async def get_team_callbacks( user_api_key_dict=user_api_key_dict, ) - # Retrieve team callback settings from metadata - team_metadata = _existing_team.metadata - team_callback_settings = team_metadata.get("callback_settings", {}) - - # Convert to TeamCallbackMetadata object for consistent structure - team_callback_settings_obj = TeamCallbackMetadata(**team_callback_settings) + team_callback_settings_obj = _resolve_team_callbacks(_existing_team.metadata) return { "status": "success", 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..9a70bd45d43 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 @@ -459,3 +459,117 @@ 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_get_team_callbacks_returns_callbacks_written_by_add_team_callbacks(monkeypatch): + """Regression: callbacks registered via POST land in metadata["logging"], so a + GET that only reads metadata["callback_settings"] reports an empty list.""" + from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars + + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-32-bytes-aaaaaaaaaaaaaa") + metadata = encrypt_callback_vars( + { + "logging": [ + { + "callback_name": "langsmith", + "callback_type": "success", + "callback_vars": { + "langsmith_api_key": "lsv2-real-secret", + "langsmith_project": "tenant-project", + }, + }, + { + "callback_name": "langfuse", + "callback_type": "failure", + "callback_vars": {"langfuse_public_key": "pk-lf-real"}, + }, + ] + } + ) + mock_prisma = _patch_prisma(_team_row(team_id="team-1", metadata=metadata)) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.master_key", None), + ): + response = await get_team_callbacks( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=_admin_auth(), + ) + + assert response["data"]["success_callbacks"] == ["langsmith"] + assert response["data"]["failure_callbacks"] == ["langfuse"] + assert response["data"]["callback_vars"] == { + "langsmith_api_key": "***REDACTED***", + "langsmith_project": "tenant-project", + "langfuse_public_key": "***REDACTED***", + } + assert "lsv2-real-secret" not in json.dumps(response) + assert "pk-lf-real" not in json.dumps(response) + + +@pytest.mark.asyncio +async def test_get_team_callbacks_merges_callback_settings_and_logging(): + """Teams configured through the older callback_settings shape keep working, and + a team carrying both shapes reports the union.""" + metadata = { + "callback_settings": { + "success_callback": ["gcs_bucket"], + "failure_callback": [], + "callback_vars": {"gcs_bucket_name": "team-bucket"}, + }, + "logging": [ + { + "callback_name": "langsmith", + "callback_type": "success_and_failure", + "callback_vars": {"langsmith_project": "tenant-project"}, + }, + {"callback_name": "not-a-valid-entry"}, + ], + } + mock_prisma = _patch_prisma(_team_row(team_id="team-1", metadata=metadata)) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + response = await get_team_callbacks( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=_admin_auth(), + ) + + assert response["data"]["success_callbacks"] == ["gcs_bucket", "langsmith"] + assert response["data"]["failure_callbacks"] == ["langsmith"] + assert response["data"]["callback_vars"] == { + "gcs_bucket_name": "team-bucket", + "langsmith_project": "tenant-project", + } + + +@pytest.mark.asyncio +async def test_disable_team_logging_clears_logging_entries(): + """disable_team_logging must clear the `logging` slot too: that is the slot + request-time callback resolution reads, so leaving it populated keeps the + team's callbacks running (and visible on the GET) after a disable.""" + metadata = { + "logging": [ + { + "callback_name": "langsmith", + "callback_type": "success", + "callback_vars": {"langsmith_project": "tenant-project"}, + } + ] + } + mock_prisma = _patch_prisma(_team_row(team_id="team-1", metadata=metadata)) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + await disable_team_logging( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=_admin_auth(), + litellm_changed_by=None, + ) + + written = json.loads(mock_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"]) + assert written["logging"] == [] + assert written["callback_settings"]["success_callback"] == []