fix(team callbacks): return callbacks registered via POST from GET /team/{team_id}/callback

get_team_callbacks only read metadata.callback_settings, while add_team_callbacks writes to metadata.logging, so any callback registered through the API came back as an empty list even though the UI and request-time resolution both showed it. Resolve both slots, redact credential-bearing callback_vars, and have disable_team_logging clear the logging slot it left behind.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
milan 2026-07-28 19:45:11 +00:00
parent b930e2fc2b
commit 6f4dabebca
2 changed files with 178 additions and 7 deletions

View file

@ -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",

View file

@ -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"] == []