diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 025b7c4210e..c39383ed25a 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -9,6 +9,7 @@ import copy import json import traceback 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 +28,10 @@ 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, +) 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 @@ -385,6 +389,48 @@ async def disable_team_logging( ) +def _team_callback_metadata_from_metadata(team_metadata: Any) -> TeamCallbackMetadata: + """Build the effective ``TeamCallbackMetadata`` from a team's stored metadata. + + Mirrors the request-time precedence in + ``litellm_pre_call_utils._get_dynamic_logging_metadata``: callbacks added via + ``POST /team/{team_id}/callback`` live under ``team_metadata["logging"]`` (a + list of ``AddTeamCallback`` dicts), while older rows carry a pre-aggregated + ``team_metadata["callback_settings"]``. Prefer ``logging`` and fall back to + ``callback_settings``, decrypting stored callback_vars first. + """ + from litellm.proxy.litellm_pre_call_utils import ( + _get_validated_callback_metadata, + convert_key_logging_metadata_to_callback, + ) + + if not isinstance(team_metadata, dict): + return TeamCallbackMetadata() + + decrypted_metadata = decrypt_callback_vars(team_metadata) + + logging_entries = decrypted_metadata.get("logging") + if isinstance(logging_entries, list) and logging_entries: + callbacks = tuple( + callback + for item in logging_entries + if (callback := _get_validated_callback_metadata(item=item, source="team-level")) is not None + ) + if callbacks: + return reduce( + lambda acc, callback: convert_key_logging_metadata_to_callback( + data=callback, team_callback_settings_obj=acc + ), + callbacks, + TeamCallbackMetadata(), + ) + + callback_settings = decrypted_metadata.get("callback_settings") + if isinstance(callback_settings, dict): + return TeamCallbackMetadata(**callback_settings) + return TeamCallbackMetadata() + + @router.get( "/team/{team_id:path}/callback", tags=["team management"], @@ -443,11 +489,7 @@ async def get_team_callbacks( ) # 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 = _team_callback_metadata_from_metadata(_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..c517b65de27 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,113 @@ 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_stored_under_logging(): + """GET must surface callbacks written by POST, which persists them under + ``metadata["logging"]`` rather than the deprecated ``callback_settings``.""" + mock_prisma = _patch_prisma( + _team_row( + team_id="team-1", + metadata={ + "logging": [ + { + "callback_name": "langsmith", + "callback_type": "success", + "callback_vars": { + "langsmith_api_key": "ls-key", + "langsmith_project": "proj", + }, + } + ] + }, + ) + ) + + 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(), + ) + + data = response["data"] + assert data["success_callbacks"] == ["langsmith"] + assert data["failure_callbacks"] == [] + assert data["callback_vars"]["langsmith_project"] == "proj" + + +@pytest.mark.asyncio +async def test_add_then_get_team_callbacks_round_trip(monkeypatch): + """End-to-end regression: a callback added via POST must appear in the GET + response with decrypted callback_vars, matching what the proxy applies.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-32-bytes-aaaaaaaaaaaaaa") + mock_prisma = _patch_prisma(_team_row(team_id="team-1", metadata={"logging": []})) + + 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), + ): + await add_team_callbacks( + data=AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={ + "langfuse_public_key": "pk-lf-real-public", + "langfuse_secret_key": "sk-lf-real-secret", + }, + ), + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=_admin_auth(), + litellm_changed_by=None, + ) + + written_metadata = json.loads( + mock_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"] + ) + mock_prisma.get_data = AsyncMock( + return_value=_team_row(team_id="team-1", metadata=written_metadata) + ) + + response = await get_team_callbacks( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=_admin_auth(), + ) + + data = response["data"] + assert data["success_callbacks"] == ["langfuse"] + assert data["callback_vars"]["langfuse_public_key"] == "pk-lf-real-public" + assert data["callback_vars"]["langfuse_secret_key"] == "sk-lf-real-secret" + + +@pytest.mark.asyncio +async def test_get_team_callbacks_falls_back_to_legacy_callback_settings(): + """Rows written in the deprecated ``callback_settings`` shape must still be + returned so pre-existing teams keep working.""" + mock_prisma = _patch_prisma( + _team_row( + team_id="team-1", + metadata={ + "callback_settings": { + "success_callback": ["langfuse"], + "failure_callback": [], + "callback_vars": {"langfuse_public_key": "pk-legacy"}, + } + }, + ) + ) + + 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(), + ) + + data = response["data"] + assert data["success_callbacks"] == ["langfuse"] + assert data["callback_vars"]["langfuse_public_key"] == "pk-legacy"