Merge pull request #17522 from BerriAI/litellm_custom_webhook_fix

[Fix] Custom Callback on UI
This commit is contained in:
yuneng-jiang 2025-12-06 22:22:02 -08:00 • committed by GitHub
commit e2e35c3f87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 70 additions and 40 deletions

View file

@ -42,21 +42,21 @@
"description": "Braintrust Logging Integration"
},
{
"id": "custom_callback_api",
"id": "generic_api",
"displayName": "Custom Callback API",
"logo": "custom.svg",
"supports_key_team_logging": true,
"dynamic_params": {
"custom_callback_api_url": {
"GENERIC_LOGGER_ENDPOINT": {
"type": "text",
"ui_name": "Callback URL",
"description": "Your custom webhook/API endpoint URL to receive logs",
"required": true
},
"custom_callback_api_headers": {
"GENERIC_LOGGER_HEADERS": {
"type": "text",
"ui_name": "Headers (JSON)",
"description": "Custom HTTP headers as JSON string (e.g., {\"Authorization\": \"Bearer token\"})",
"ui_name": "Headers",
"description": "Custom HTTP headers as a comma-separated string (e.g., Authorization: Bearer token, Content-Type: application/json)",
"required": false
}
},

View file

@ -2577,7 +2577,13 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
custom_callback_api: CallbackOnUI = CallbackOnUI(
litellm_callback_name="custom_callback_api",
litellm_callback_params=["GENERIC_LOGGER_ENDPOINT"],
litellm_callback_params=["GENERIC_LOGGER_ENDPOINT", "GENERIC_LOGGER_HEADERS"],
ui_callback_name="Custom Callback API",
)
generic_api: CallbackOnUI = CallbackOnUI(
litellm_callback_name="generic_api",
litellm_callback_params=["GENERIC_LOGGER_ENDPOINT", "GENERIC_LOGGER_HEADERS"],
ui_callback_name="Custom Callback API",
)

View file

@ -6,9 +6,6 @@ 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,
@ -436,11 +433,7 @@ def process_callback(_callback: str, callback_type: str, environment_variables:
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
env_vars_dict[_var] = env_variable
return {
"name": _callback,

View file

@ -9478,7 +9478,7 @@ async def get_config(): # noqa: PLR0915
_litellm_settings = config_data.get("litellm_settings", {})
_general_settings = config_data.get("general_settings", {})
environment_variables = config_data.get("environment_variables", {})
_success_callbacks = _litellm_settings.get("success_callback", [])
_failure_callbacks = _litellm_settings.get("failure_callback", [])
_success_and_failure_callbacks = _litellm_settings.get("callbacks", [])

View file

@ -2497,9 +2497,6 @@ async def test_get_config_callbacks_with_all_types(client_no_auth):
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")
@ -2549,7 +2546,7 @@ async def test_get_config_callbacks_with_all_types(client_no_auth):
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.
for each callback type. Values are returned as-is from the config (no decryption).
"""
from litellm.proxy.proxy_server import ProxyConfig
@ -2561,8 +2558,8 @@ async def test_get_config_callbacks_environment_variables(client_no_auth):
"callbacks": ["otel"]
},
"environment_variables": {
"LANGFUSE_PUBLIC_KEY": "encrypted-public-key",
"LANGFUSE_SECRET_KEY": "encrypted-secret-key",
"LANGFUSE_PUBLIC_KEY": "test-public-key",
"LANGFUSE_SECRET_KEY": "test-secret-key",
"LANGFUSE_HOST": "https://cloud.langfuse.com",
"OTEL_EXPORTER": "otlp",
"OTEL_ENDPOINT": "http://localhost:4317",
@ -2571,19 +2568,10 @@ async def test_get_config_callbacks_environment_variables(client_no_auth):
"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")
@ -2600,12 +2588,12 @@ async def test_get_config_callbacks_environment_variables(client_no_auth):
assert langfuse_callback["type"] == "success"
assert "variables" in langfuse_callback
# Verify langfuse env vars are present and decrypted
# Verify langfuse env vars are present (values returned as-is, no decryption)
langfuse_vars = langfuse_callback["variables"]
assert "LANGFUSE_PUBLIC_KEY" in langfuse_vars
assert langfuse_vars["LANGFUSE_PUBLIC_KEY"] == "decrypted-encrypted-public-key"
assert langfuse_vars["LANGFUSE_PUBLIC_KEY"] == "test-public-key"
assert "LANGFUSE_SECRET_KEY" in langfuse_vars
assert langfuse_vars["LANGFUSE_SECRET_KEY"] == "decrypted-encrypted-secret-key"
assert langfuse_vars["LANGFUSE_SECRET_KEY"] == "test-secret-key"
assert "LANGFUSE_HOST" in langfuse_vars
assert langfuse_vars["LANGFUSE_HOST"] == "https://cloud.langfuse.com"

View file

@ -37,13 +37,9 @@ def test_get_remaining_tokens_and_requests_from_request_data():
"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):
def test_process_callback_with_env_vars(mock_get_env_vars):
environment_variables = {
"API_KEY": "ENC_VALUE",
"API_KEY": "PLAIN_VALUE",
"UNUSED": "SHOULD_BE_IGNORED",
}
@ -56,7 +52,7 @@ def test_process_callback_with_env_vars(mock_decrypt, mock_get_env_vars):
assert result["name"] == "my_callback"
assert result["type"] == "input"
assert result["variables"] == {
"API_KEY": "decrypted-API_KEY",
"API_KEY": "PLAIN_VALUE",
"MISSING_VAR": None,
}

View file

@ -247,6 +247,53 @@ def test_update_config_fields_deep_merge_db_wins():
assert rs["routing_mode"] == "cost_optimized"
def test_get_config_custom_callback_api_env_vars(monkeypatch):
"""
Ensure /get/config/callbacks returns custom callback env vars when both custom values are provided.
"""
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
# Mock config with custom_callback_api enabled and generic logger env vars present
config_data = {
"litellm_settings": {"success_callback": ["custom_callback_api"]},
"general_settings": {},
"environment_variables": {
"GENERIC_LOGGER_ENDPOINT": "https://callback.example.com",
"GENERIC_LOGGER_HEADERS": "Auth: token",
},
}
# Mock proxy_config.get_config and router settings
mock_router = MagicMock()
mock_router.get_settings.return_value = {}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr(
proxy_config, "get_config", AsyncMock(return_value=config_data)
)
# Bypass auth dependency
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
client = TestClient(app)
try:
response = client.get("/get/config/callbacks")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
callbacks = response.json()["callbacks"]
custom_cb = next(
(cb for cb in callbacks if cb["name"] == "custom_callback_api"), None
)
assert custom_cb is not None
assert custom_cb["variables"] == {
"GENERIC_LOGGER_ENDPOINT": "https://callback.example.com",
"GENERIC_LOGGER_HEADERS": "Auth: token",
}
# Mock Prisma
class MockPrisma:
def __init__(self, database_url=None, proxy_logging_obj=None, http_client=None):