mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
b930e2fc2b
commit
6f4dabebca
2 changed files with 178 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue