Merge pull request #16335 from BerriAI/litellm_ui_callback_fix

[Feature] Show all callbacks on UI
This commit is contained in:
yuneng-jiang 2025-12-05 12:35:58 -08:00 • committed by GitHub
commit 62045477ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 313 additions and 60 deletions

View file

@ -80,6 +80,44 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
self.turn_off_message_logging = turn_off_message_logging
pass
@staticmethod
def get_callback_env_vars(callback_name: Optional[str] = None) -> List[str]:
"""
Return the environment variables associated with a given callback
name as defined in the proxy callback registry.
Args:
callback_name: The name of the callback to look up.
Returns:
List[str]: A list of required environment variable names.
"""
if callback_name is None:
return []
normalized_name = callback_name.lower()
alias_map = {
"langfuse_otel": "langfuse",
}
lookup_name = alias_map.get(normalized_name, normalized_name)
try:
from litellm.proxy._types import AllCallbacks
except Exception:
return []
callbacks = AllCallbacks()
callback_info = getattr(callbacks, lookup_name, None)
if callback_info is None:
return []
params = getattr(callback_info, "litellm_callback_params", None)
if not params:
return []
return list(params)
def log_pre_api_call(self, model, messages, kwargs):
pass

View file

@ -2614,6 +2614,14 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
ui_callback_name="Lago Billing",
)
traceloop: CallbackOnUI = CallbackOnUI(
litellm_callback_name="traceloop",
litellm_callback_params=[
"TRACELOOP_API_KEY",
],
ui_callback_name="Traceloop",
)
class SpendLogsMetadata(TypedDict):
"""

View file

@ -3,8 +3,12 @@ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional
import litellm
from litellm import get_secret
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import CommonProxyErrors, LiteLLMPromptInjectionParams
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
)
from litellm.types.utils import (
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
@ -420,6 +424,27 @@ def get_metadata_variable_name_from_kwargs(
return "litellm_metadata" if "litellm_metadata" in kwargs else "metadata"
def process_callback(_callback: str, callback_type: str, environment_variables: dict) -> dict:
"""Process a single callback and return its data with environment variables"""
env_vars = CustomLogger.get_callback_env_vars(_callback)
env_vars_dict: dict[str, str | None] = {}
for _var in env_vars:
env_variable = environment_variables.get(_var, None)
if env_variable is None:
env_vars_dict[_var] = None
else:
# decode + decrypt the value
decrypted_value = decrypt_value_helper(
value=env_variable, key=_var
)
env_vars_dict[_var] = decrypted_value
return {
"name": _callback,
"variables": env_vars_dict,
"type": callback_type
}
def normalize_callback_names(callbacks: Iterable[Any]) -> List[Any]:
if callbacks is None:
return []

View file

@ -54,6 +54,7 @@ from litellm.types.utils import (
TokenCountResponse,
)
from litellm.utils import load_credentials_from_list
from litellm.proxy.common_utils.callback_utils import process_callback
if TYPE_CHECKING:
from aiohttp import ClientSession
@ -168,6 +169,7 @@ from litellm.constants import (
)
from litellm.exceptions import RejectedRequestError
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_litellm_metadata_from_kwargs,
@ -9475,8 +9477,10 @@ async def get_config(): # noqa: PLR0915
_general_settings = config_data.get("general_settings", {})
environment_variables = config_data.get("environment_variables", {})
# check if "langfuse" in litellm_settings
_success_callbacks = _litellm_settings.get("success_callback", [])
_failure_callbacks = _litellm_settings.get("failure_callback", [])
_success_and_failure_callbacks = _litellm_settings.get("callbacks", [])
_data_to_return = []
"""
[
@ -9487,70 +9491,20 @@ async def get_config(): # noqa: PLR0915
"LANGFUSE_SECRET_KEY": "value",
"LANGFUSE_HOST": "value"
},
"type": "success"
}
]
"""
for _callback in _success_callbacks:
if _callback != "langfuse":
if _callback == "openmeter":
env_vars = [
"OPENMETER_API_KEY",
]
elif _callback == "braintrust":
env_vars = [
"BRAINTRUST_API_KEY",
"BRAINTRUST_API_BASE",
]
elif _callback == "traceloop":
env_vars = ["TRACELOOP_API_KEY"]
elif _callback == "custom_callback_api":
env_vars = ["GENERIC_LOGGER_ENDPOINT"]
elif _callback == "otel":
env_vars = ["OTEL_EXPORTER", "OTEL_ENDPOINT", "OTEL_HEADERS"]
elif _callback == "langsmith":
env_vars = [
"LANGSMITH_API_KEY",
"LANGSMITH_PROJECT",
"LANGSMITH_DEFAULT_RUN_NAME",
]
else:
env_vars = []
env_vars_dict = {}
for _var in env_vars:
env_variable = environment_variables.get(_var, None)
if env_variable is None:
env_vars_dict[_var] = None
else:
# decode + decrypt the value
decrypted_value = decrypt_value_helper(
value=env_variable, key=_var
)
env_vars_dict[_var] = decrypted_value
_data_to_return.append({"name": _callback, "variables": env_vars_dict})
elif _callback == "langfuse":
_langfuse_vars = [
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_HOST",
]
_langfuse_env_vars = {}
for _var in _langfuse_vars:
env_variable = environment_variables.get(_var, None)
if env_variable is None:
_langfuse_env_vars[_var] = None
else:
# decode + decrypt the value
decrypted_value = decrypt_value_helper(
value=env_variable, key=_var
)
_langfuse_env_vars[_var] = decrypted_value
_data_to_return.append(
{"name": _callback, "variables": _langfuse_env_vars}
)
_data_to_return.append(process_callback(_callback, "success", environment_variables))
for _callback in _failure_callbacks:
_data_to_return.append(process_callback(_callback, "failure", environment_variables))
for _callback in _success_and_failure_callbacks:
_data_to_return.append(process_callback(_callback, "success_and_failure", environment_variables))
# Check if slack alerting is on
_alerting = _general_settings.get("alerting", [])

View file

@ -103,6 +103,24 @@ class TmpFunction:
)
def test_get_callback_env_vars():
env_vars = CustomLogger.get_callback_env_vars("langfuse")
assert env_vars == [
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_HOST",
]
alias_env_vars = CustomLogger.get_callback_env_vars("langfuse_otel")
assert alias_env_vars == env_vars
missing_env_vars = CustomLogger.get_callback_env_vars("does_not_exist")
assert missing_env_vars == []
none_env_vars = CustomLogger.get_callback_env_vars(None)
assert none_env_vars == []
@pytest.mark.asyncio
async def test_async_chat_openai_stream():
try:

View file

@ -2464,6 +2464,169 @@ def test_non_root_ui_path_logic(monkeypatch, tmp_path, ui_exists, ui_has_content
assert mock_logger.info.call_count == 0
@pytest.mark.asyncio
async def test_get_config_callbacks_with_all_types(client_no_auth):
"""
Test that /get/config/callbacks returns all three callback types:
- success_callback with type="success"
- failure_callback with type="failure"
- callbacks (success_and_failure) with type="success_and_failure"
"""
from litellm.proxy.proxy_server import ProxyConfig
# Create a mock config with all three callback types
mock_config_data = {
"litellm_settings": {
"success_callback": ["langfuse", "braintrust"],
"failure_callback": ["sentry"],
"callbacks": ["otel", "langsmith"]
},
"environment_variables": {
"LANGFUSE_PUBLIC_KEY": "test-public-key",
"LANGFUSE_SECRET_KEY": "test-secret-key",
"LANGFUSE_HOST": "https://test.langfuse.com",
"BRAINTRUST_API_KEY": "test-braintrust-key",
"OTEL_EXPORTER": "otlp",
"OTEL_ENDPOINT": "http://localhost:4317",
"LANGSMITH_API_KEY": "test-langsmith-key",
},
"general_settings": {}
}
proxy_config = getattr(litellm.proxy.proxy_server, "proxy_config")
with patch.object(
proxy_config, "get_config", new=AsyncMock(return_value=mock_config_data)
), patch(
"litellm.proxy.common_utils.callback_utils.decrypt_value_helper",
side_effect=lambda value, key=None: value
):
response = client_no_auth.get("/get/config/callbacks")
assert response.status_code == 200
result = response.json()
# Verify response structure
assert "status" in result
assert result["status"] == "success"
assert "callbacks" in result
callbacks = result["callbacks"]
# Verify we have all 5 callbacks (2 success + 1 failure + 2 success_and_failure)
assert len(callbacks) == 5
# Group callbacks by type
success_callbacks = [cb for cb in callbacks if cb.get("type") == "success"]
failure_callbacks = [cb for cb in callbacks if cb.get("type") == "failure"]
success_and_failure_callbacks = [cb for cb in callbacks if cb.get("type") == "success_and_failure"]
# Verify all callbacks have required fields
for callback in callbacks:
assert "name" in callback
assert "variables" in callback
assert "type" in callback
assert callback["type"] in ["success", "failure", "success_and_failure"]
# Verify success callbacks
assert len(success_callbacks) == 2
success_names = [cb["name"] for cb in success_callbacks]
assert "langfuse" in success_names
assert "braintrust" in success_names
# Verify failure callbacks
assert len(failure_callbacks) == 1
assert failure_callbacks[0]["name"] == "sentry"
# Verify success_and_failure callbacks
assert len(success_and_failure_callbacks) == 2
success_and_failure_names = [cb["name"] for cb in success_and_failure_callbacks]
assert "otel" in success_and_failure_names
assert "langsmith" in success_and_failure_names
@pytest.mark.asyncio
async def test_get_config_callbacks_environment_variables(client_no_auth):
"""
Test that /get/config/callbacks correctly includes environment variables
for each callback type with proper decryption.
"""
from litellm.proxy.proxy_server import ProxyConfig
# Create a mock config with callbacks and their env vars
mock_config_data = {
"litellm_settings": {
"success_callback": ["langfuse"],
"failure_callback": [],
"callbacks": ["otel"]
},
"environment_variables": {
"LANGFUSE_PUBLIC_KEY": "encrypted-public-key",
"LANGFUSE_SECRET_KEY": "encrypted-secret-key",
"LANGFUSE_HOST": "https://cloud.langfuse.com",
"OTEL_EXPORTER": "otlp",
"OTEL_ENDPOINT": "http://localhost:4317",
"OTEL_HEADERS": "key=value",
},
"general_settings": {}
}
# Mock decrypt to prepend "decrypted-" to values
def mock_decrypt(value, key=None):
if value and isinstance(value, str) and "encrypted" in value:
return f"decrypted-{value}"
return value
proxy_config = getattr(litellm.proxy.proxy_server, "proxy_config")
with patch.object(
proxy_config, "get_config", new=AsyncMock(return_value=mock_config_data)
), patch(
"litellm.proxy.common_utils.callback_utils.decrypt_value_helper",
side_effect=mock_decrypt
):
response = client_no_auth.get("/get/config/callbacks")
assert response.status_code == 200
result = response.json()
callbacks = result["callbacks"]
# Find langfuse callback (success type)
langfuse_callback = next(
(cb for cb in callbacks if cb["name"] == "langfuse"), None
)
assert langfuse_callback is not None
assert langfuse_callback["type"] == "success"
assert "variables" in langfuse_callback
# Verify langfuse env vars are present and decrypted
langfuse_vars = langfuse_callback["variables"]
assert "LANGFUSE_PUBLIC_KEY" in langfuse_vars
assert langfuse_vars["LANGFUSE_PUBLIC_KEY"] == "decrypted-encrypted-public-key"
assert "LANGFUSE_SECRET_KEY" in langfuse_vars
assert langfuse_vars["LANGFUSE_SECRET_KEY"] == "decrypted-encrypted-secret-key"
assert "LANGFUSE_HOST" in langfuse_vars
assert langfuse_vars["LANGFUSE_HOST"] == "https://cloud.langfuse.com"
# Find otel callback (success_and_failure type)
otel_callback = next(
(cb for cb in callbacks if cb["name"] == "otel"), None
)
assert otel_callback is not None
assert otel_callback["type"] == "success_and_failure"
assert "variables" in otel_callback
# Verify otel env vars are present
otel_vars = otel_callback["variables"]
assert "OTEL_EXPORTER" in otel_vars
assert otel_vars["OTEL_EXPORTER"] == "otlp"
assert "OTEL_ENDPOINT" in otel_vars
assert otel_vars["OTEL_ENDPOINT"] == "http://localhost:4317"
assert "OTEL_HEADERS" in otel_vars
assert otel_vars["OTEL_HEADERS"] == "key=value"
@pytest.mark.asyncio
async def test_update_config_success_callback_normalization():
"""

View file

@ -10,6 +10,9 @@ from litellm.proxy.common_utils.callback_utils import (
normalize_callback_names,
)
from unittest.mock import patch
from litellm.proxy.common_utils.callback_utils import process_callback
def test_get_remaining_tokens_and_requests_from_request_data():
model_group = "openrouter/google/gemini-2.0-flash-001"
@ -30,6 +33,50 @@ def test_get_remaining_tokens_and_requests_from_request_data():
}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["API_KEY", "MISSING_VAR"],
)
@patch(
"litellm.proxy.common_utils.callback_utils.decrypt_value_helper",
side_effect=lambda value, key: f"decrypted-{key}",
)
def test_process_callback_with_env_vars(mock_decrypt, mock_get_env_vars):
environment_variables = {
"API_KEY": "ENC_VALUE",
"UNUSED": "SHOULD_BE_IGNORED",
}
result = process_callback(
_callback="my_callback",
callback_type="input",
environment_variables=environment_variables,
)
assert result["name"] == "my_callback"
assert result["type"] == "input"
assert result["variables"] == {
"API_KEY": "decrypted-API_KEY",
"MISSING_VAR": None,
}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=[],
)
def test_process_callback_with_no_required_env_vars(mock_get_env_vars):
result = process_callback(
_callback="another_callback",
callback_type="output",
environment_variables={"SHOULD_NOT_BE_USED": "VALUE"},
)
assert result["name"] == "another_callback"
assert result["type"] == "output"
assert result["variables"] == {}
def test_normalize_callback_names_none_returns_empty_list():
assert normalize_callback_names(None) == []
assert normalize_callback_names([]) == []