Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions litellm/integrations/custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ def __init__(
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

Expand Down
8 changes: 8 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2503,6 +2503,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):
"""
Expand Down
96 changes: 36 additions & 60 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def generate_feedback_box():
)
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,
Expand Down Expand Up @@ -9572,8 +9573,33 @@ 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
# Helper function to process callbacks and get environment variables
def process_callback(_callback: str, callback_type: str) -> dict:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets not add bloat in this file

"""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
}

_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 = []
"""
[
Expand All @@ -9584,70 +9610,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"))

for _callback in _failure_callbacks:
_data_to_return.append(process_callback(_callback, "failure"))

for _callback in _success_and_failure_callbacks:
_data_to_return.append(process_callback(_callback, "success_and_failure"))

# Check if slack alerting is on
_alerting = _general_settings.get("alerting", [])
Expand Down
18 changes: 18 additions & 0 deletions tests/local_testing/test_custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ async def async_test_logging_fn(self, kwargs, completion_obj, start_time, end_ti
)


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:
Expand Down
163 changes: 163 additions & 0 deletions tests/proxy_unit_tests/test_proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2371,3 +2371,166 @@ def test_non_root_ui_path_logic(monkeypatch, tmp_path, ui_exists, ui_has_content
error_calls = [call[0][0] for call in mock_logger.error.call_args_list]
assert any("Path exists:" in call for call in error_calls)
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 (generic) with type="generic"
"""
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.proxy_server.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 generic)
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"]
generic_callbacks = [cb for cb in callbacks if cb.get("type") == "generic"]

# 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", "generic"]

# 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 generic callbacks
assert len(generic_callbacks) == 2
generic_names = [cb["name"] for cb in generic_callbacks]
assert "otel" in generic_names
assert "langsmith" in generic_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.proxy_server.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 (generic type)
otel_callback = next(
(cb for cb in callbacks if cb["name"] == "otel"), None
)
assert otel_callback is not None
assert otel_callback["type"] == "generic"
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"
Loading
Loading