LIT-5281: Add runtime callback visibility to UI Logging & Alerts page

Implement callback discovery for runtime-registered callbacks and merge them
with configured callbacks in the /get/config/callbacks endpoint. Runtime-only
callbacks are marked with read_only: true, and the UI hides edit/delete/test
controls for these rows.

Changes:
- Proxy server: Add _get_runtime_callbacks() to discover callbacks from
  litellm.success_callback, litellm._async_success_callback,
  litellm.failure_callback, litellm._async_failure_callback, litellm.callbacks
- Normalize callback aliases (opentelemetry→otel, s3_v2→s3, etc.)
- Mark runtime-only callbacks as read_only: true in response
- UI: Hide actions menu for read_only callbacks
- Update AlertingObject type with read_only property
- Add comprehensive unit tests for discovery and normalization

Resolves LIT-5281
This commit is contained in:
Yucheng Zhu 2026-08-29 15:30:18 -07:00
parent 36ea28b092
commit be8ab6ebce
6 changed files with 341 additions and 3 deletions

View file

@ -16992,6 +16992,79 @@ async def delete_callback(
)
def _get_runtime_callbacks() -> list[tuple[str, str]]:
"""
Discover callbacks registered in LiteLLM runtime memory (not from config).
Returns a list of (callback_name, callback_type) tuples where callback_type
is one of: "success", "failure", "success_and_failure".
Only discovers callbacks from known runtime sources:
- litellm.success_callback
- litellm._async_success_callback
- litellm.failure_callback
- litellm._async_failure_callback
- litellm.callbacks (success_and_failure)
"""
runtime_callbacks: list[tuple[str, str]] = []
# Discover success callbacks
if litellm.success_callback:
callbacks = litellm.success_callback if isinstance(litellm.success_callback, list) else [litellm.success_callback]
for cb in callbacks:
if isinstance(cb, str):
runtime_callbacks.append((cb, "success"))
# Discover async success callbacks
if litellm._async_success_callback:
callbacks = litellm._async_success_callback if isinstance(litellm._async_success_callback, list) else [litellm._async_success_callback]
for cb in callbacks:
if isinstance(cb, str):
runtime_callbacks.append((cb, "success"))
# Discover failure callbacks
if litellm.failure_callback:
callbacks = litellm.failure_callback if isinstance(litellm.failure_callback, list) else [litellm.failure_callback]
for cb in callbacks:
if isinstance(cb, str):
runtime_callbacks.append((cb, "failure"))
# Discover async failure callbacks
if litellm._async_failure_callback:
callbacks = litellm._async_failure_callback if isinstance(litellm._async_failure_callback, list) else [litellm._async_failure_callback]
for cb in callbacks:
if isinstance(cb, str):
runtime_callbacks.append((cb, "failure"))
# Discover success_and_failure callbacks (litellm.callbacks)
if litellm.callbacks:
callbacks = litellm.callbacks if isinstance(litellm.callbacks, list) else [litellm.callbacks]
for cb in callbacks:
if isinstance(cb, str):
runtime_callbacks.append((cb, "success_and_failure"))
return runtime_callbacks
def _normalize_callback_alias(callback_name: str) -> str:
"""
Normalize callback names to canonical aliases.
Maps known aliases to their canonical forms:
- opentelemetry -> otel
- s3_v2 -> s3
- aws_sqs -> sqs
- custom_callback_api -> generic_api
"""
alias_map = {
"opentelemetry": "otel",
"s3_v2": "s3",
"aws_sqs": "sqs",
"custom_callback_api": "generic_api",
}
return alias_map.get(callback_name, callback_name)
@router.get(
"/get/config/callbacks",
tags=["config.yaml"],
@ -17049,14 +17122,39 @@ async def get_config(
"""
# Track which callbacks come from config (to mark them as NOT read_only)
config_callback_names: set[str] = set()
for _callback in _success_callbacks:
_data_to_return.append(process_callback(_callback, "success", environment_variables))
callback_obj = process_callback(_callback, "success", environment_variables)
_data_to_return.append(callback_obj)
config_callback_names.add(_callback)
for _callback in _failure_callbacks:
_data_to_return.append(process_callback(_callback, "failure", environment_variables))
callback_obj = process_callback(_callback, "failure", environment_variables)
_data_to_return.append(callback_obj)
config_callback_names.add(_callback)
for _callback in _success_and_failure_callbacks:
_data_to_return.append(process_callback(_callback, "success_and_failure", environment_variables))
callback_obj = process_callback(_callback, "success_and_failure", environment_variables)
_data_to_return.append(callback_obj)
config_callback_names.add(_callback)
# Discover runtime-only callbacks (registered in litellm memory but not in config)
runtime_callbacks = _get_runtime_callbacks()
runtime_callback_names: set[str] = set()
for runtime_callback, callback_type in runtime_callbacks:
# Normalize the alias
normalized_name = _normalize_callback_alias(runtime_callback)
runtime_callback_names.add(normalized_name)
# Only add if not already in config (runtime-only)
if normalized_name not in config_callback_names and normalized_name not in {c["name"] for c in _data_to_return}:
callback_obj = process_callback(normalized_name, callback_type, environment_variables)
# Mark as read_only since it's runtime-only
callback_obj["read_only"] = True
_data_to_return.append(callback_obj)
_data_to_return = _apply_callback_role_gate(_data_to_return, is_full_admin)

View file

@ -0,0 +1,80 @@
"""Integration test for /get/config/callbacks endpoint with runtime callbacks."""
import pytest
import litellm
from litellm.proxy.proxy_server import _get_runtime_callbacks, _normalize_callback_alias
class TestCallbackVisibility:
"""Test that configured callbacks are not marked read_only and runtime-only are."""
def test_configured_callback_not_read_only(self):
"""Test that a callback from config doesn't get marked as read_only."""
# The configured callback should not have read_only flag
# (it's only added for runtime-only callbacks in the route)
original = litellm.success_callback
try:
# Clear runtime callbacks
litellm.success_callback = None
litellm.failure_callback = None
litellm._async_success_callback = None
litellm._async_failure_callback = None
litellm.callbacks = None
# Verify nothing is discovered
runtime = _get_runtime_callbacks()
assert len(runtime) == 0
finally:
litellm.success_callback = original
def test_runtime_only_callback_discovered(self):
"""Test that a runtime-only callback is discovered correctly."""
original = litellm.callbacks
try:
litellm.callbacks = ["otel"]
runtime = _get_runtime_callbacks()
assert ("otel", "success_and_failure") in runtime
finally:
litellm.callbacks = original
def test_multiple_runtime_callbacks(self):
"""Test that multiple runtime callbacks are all discovered."""
original_success = litellm.success_callback
original_failure = litellm.failure_callback
original_combined = litellm.callbacks
try:
litellm.success_callback = ["langfuse"]
litellm.failure_callback = ["generic_api"]
litellm.callbacks = ["otel"]
runtime = _get_runtime_callbacks()
runtime_dict = {name: ctype for name, ctype in runtime}
assert "langfuse" in runtime_dict
assert runtime_dict["langfuse"] == "success"
assert "generic_api" in runtime_dict
assert runtime_dict["generic_api"] == "failure"
assert "otel" in runtime_dict
assert runtime_dict["otel"] == "success_and_failure"
finally:
litellm.success_callback = original_success
litellm.failure_callback = original_failure
litellm.callbacks = original_combined
def test_alias_normalization_on_runtime_callbacks(self):
"""Test that runtime callbacks have aliases normalized."""
original = litellm.callbacks
try:
# Register an alias-named callback
litellm.callbacks = ["opentelemetry"]
runtime = _get_runtime_callbacks()
# Should discover as "opentelemetry", not normalized yet
assert ("opentelemetry", "success_and_failure") in runtime
# But _normalize_callback_alias should convert it
assert _normalize_callback_alias("opentelemetry") == "otel"
finally:
litellm.callbacks = original

View file

@ -0,0 +1,121 @@
"""Test runtime callback visibility in /get/config/callbacks endpoint."""
import asyncio
import pytest
import litellm
from litellm.proxy.proxy_server import _get_runtime_callbacks, _normalize_callback_alias
class TestRuntimeCallbackDiscovery:
"""Test discovery of runtime-only callbacks."""
def test_discover_success_callback(self):
"""Test discovering a callback from litellm.success_callback."""
original = litellm.success_callback
try:
litellm.success_callback = ["langfuse"]
callbacks = _get_runtime_callbacks()
callback_names = [name for name, _ in callbacks]
assert "langfuse" in callback_names
finally:
litellm.success_callback = original
def test_discover_failure_callback(self):
"""Test discovering a callback from litellm.failure_callback."""
original = litellm.failure_callback
try:
litellm.failure_callback = ["generic_api"]
callbacks = _get_runtime_callbacks()
callback_types = [(name, ctype) for name, ctype in callbacks]
assert ("generic_api", "failure") in callback_types
finally:
litellm.failure_callback = original
def test_discover_success_and_failure_callbacks(self):
"""Test discovering callbacks from litellm.callbacks."""
original = litellm.callbacks
try:
litellm.callbacks = ["otel"]
callbacks = _get_runtime_callbacks()
callback_types = [(name, ctype) for name, ctype in callbacks]
assert ("otel", "success_and_failure") in callback_types
finally:
litellm.callbacks = original
def test_no_duplicate_callbacks(self):
"""Test that the same callback isn't returned multiple times."""
original_success = litellm.success_callback
original_failure = litellm.failure_callback
try:
litellm.success_callback = ["langfuse"]
litellm.failure_callback = ["langfuse"]
callbacks = _get_runtime_callbacks()
callback_entries = [(name, ctype) for name, ctype in callbacks]
# Both should be discovered
assert ("langfuse", "success") in callback_entries
assert ("langfuse", "failure") in callback_entries
finally:
litellm.success_callback = original_success
litellm.failure_callback = original_failure
def test_ignore_non_string_callbacks(self):
"""Test that non-string callbacks are ignored."""
original = litellm.success_callback
class DummyCallback:
pass
try:
litellm.success_callback = [DummyCallback(), "langfuse"]
callbacks = _get_runtime_callbacks()
callback_names = [name for name, _ in callbacks]
# Only the string callback should be discovered
assert "langfuse" in callback_names
assert DummyCallback not in callback_names
finally:
litellm.success_callback = original
class TestCallbackAliasNormalization:
"""Test callback name alias normalization."""
def test_normalize_opentelemetry_to_otel(self):
"""Test normalizing opentelemetry to otel."""
assert _normalize_callback_alias("opentelemetry") == "otel"
def test_normalize_s3_v2_to_s3(self):
"""Test normalizing s3_v2 to s3."""
assert _normalize_callback_alias("s3_v2") == "s3"
def test_normalize_aws_sqs_to_sqs(self):
"""Test normalizing aws_sqs to sqs."""
assert _normalize_callback_alias("aws_sqs") == "sqs"
def test_normalize_custom_callback_api_to_generic_api(self):
"""Test normalizing custom_callback_api to generic_api."""
assert _normalize_callback_alias("custom_callback_api") == "generic_api"
def test_normalize_unknown_callback(self):
"""Test that unknown callbacks pass through unchanged."""
assert _normalize_callback_alias("langfuse") == "langfuse"
assert _normalize_callback_alias("generic_api") == "generic_api"
class TestGetConfigCallbacksEndpoint:
"""Test the /get/config/callbacks endpoint with runtime callbacks."""
@pytest.mark.asyncio
async def test_runtime_callbacks_marked_read_only(self):
"""Test that runtime-only callbacks are marked as read_only."""
# This test requires a running proxy server, so it's more of an integration test
# For unit test, we verify the helper functions work correctly
from litellm.proxy.common_utils.callback_utils import process_callback
# Simulate processing a runtime callback
callback_obj = process_callback("otel", "success", {})
callback_obj["read_only"] = True
assert callback_obj["read_only"] is True
assert callback_obj["name"] == "otel"
assert callback_obj["type"] == "success"

View file

@ -123,4 +123,34 @@ describe("LoggingCallbacksTable", () => {
expect(screen.getByText("Success")).toBeInTheDocument();
expect(screen.getByText("Failure")).toBeInTheDocument();
});
it("should not show actions menu for read-only (runtime-only) callbacks", () => {
const user = userEvent.setup();
const onEdit = vi.fn();
const onDelete = vi.fn();
const onTest = vi.fn();
const callback = { name: "otel", type: "success" as const, variables: baseVars, read_only: true };
render(
<LoggingCallbacksTable
callbacks={[callback]}
availableCallbacks={{
otel: {
litellm_callback_name: "otel",
litellm_callback_params: [],
ui_callback_name: "OpenTelemetry",
},
}}
onTest={onTest}
onEdit={onEdit}
onDelete={onDelete}
/>,
);
// Verify callback is shown
expect(screen.getByText("OpenTelemetry")).toBeInTheDocument();
// Verify no actions menu exists for read-only callback
const actionsButton = screen.queryByTestId("callback-actions-otel-success");
expect(actionsButton).not.toBeInTheDocument();
});
});

View file

@ -50,6 +50,12 @@ interface CallbackRowActionsProps {
}
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
// Hide actions for read-only (runtime-only) callbacks
const isReadOnly = (callback as any).read_only === true;
if (isReadOnly) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger

View file

@ -8,6 +8,9 @@ export interface AlertingObject {
// every row to render as "Success".
type?: "success" | "failure" | "success_and_failure";
variables: AlertingVariables;
// read_only is true for runtime-only callbacks (not in DB/YAML config).
// UI hides edit/delete/test controls for read-only rows.
read_only?: boolean;
}
export interface AlertingVariables {