fix(proxy): show active logging callbacks in dashboard

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yucheng Zhu 2026-08-28 14:59:21 -07:00
parent f0fadb7f99
commit f29cd0894a
14 changed files with 1599 additions and 249 deletions

View file

@ -363,6 +363,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
logger_provider: object | None = None,
meter_provider: object | None = None,
max_dynamic_tracer_providers: int = _MAX_DYNAMIC_TRACER_PROVIDERS,
register_on_proxy: bool = True,
**kwargs,
):
team_metadata_keys_override: Final = kwargs.pop("baggage_team_metadata_keys", None)
@ -414,7 +415,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
# Sample env-var / config / message_logging at init so subsequent
# _capture_in_span / _capture_in_event calls are deterministic.
self._capture_mode_cached = self._compute_capture_mode_from_init_state()
self._init_otel_logger_on_litellm_proxy()
if register_on_proxy:
self._init_otel_logger_on_litellm_proxy()
@staticmethod
def _get_litellm_resource(config: OpenTelemetryConfig) -> "_Resource":

View file

@ -172,6 +172,7 @@ class OpenTelemetryV2(CustomLogger):
tracer_provider: TracerProvider | None = None,
logger_provider: LoggerProvider | None = None,
meter_provider: "MeterProvider | None" = None,
register_on_proxy: bool = True,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -191,7 +192,8 @@ class OpenTelemetryV2(CustomLogger):
)
self._tenant_tracers = TenantTracerCache(self.config, callback_name, LITELLM_TRACER_NAME)
self._open_llm_calls: OrderedDict[str, _LLMCallSpan] = OrderedDict()
self._init_otel_logger_on_litellm_proxy()
if register_on_proxy:
self._init_otel_logger_on_litellm_proxy()
def _init_metrics(self, meter_provider: "MeterProvider | None") -> "GenAIMetricRecorder | None":
"""Create the six GenAI histograms when metrics are enabled, else ``None``.

View file

@ -246,6 +246,16 @@ else:
_PAGERDUTY_ALERTING_FACTORY: Final = PagerDutyAlerting
_in_memory_loggers: Final[list[CustomLogger]] = []
def _register_dashboard_callback(callback: CustomLogger, callback_name: str) -> None:
"""Record the dashboard identity of a factory-created callback."""
from litellm.litellm_core_utils.logging_callback_manager import (
register_dashboard_callback,
)
register_dashboard_callback(callback, callback_name)
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggingMetadata.__annotations__.keys())
### GLOBAL VARIABLES ###
@ -651,6 +661,7 @@ class Logging(LiteLLMLoggingBaseClass):
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args=_custom_logger_init_args,
**_get_request_local_otel_init_options(callback),
)
if callback_class is None:
return ()
@ -659,7 +670,13 @@ class Logging(LiteLLMLoggingBaseClass):
# resolve the name again without creds so the trace logger (OTel v2 /
# legacy agent) keeps receiving this request.
_newrelic_trace_class: Final = (
_init_custom_logger_compatible_class(callback, internal_usage_cache=None, llm_router=None)
_init_custom_logger_compatible_class(
callback,
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args=None,
**_get_request_local_otel_init_options(callback),
)
if callback == "newrelic" and _custom_logger_init_args and _custom_logger_init_args.get("newrelic_api_key")
else None
)
@ -1062,6 +1079,8 @@ class Logging(LiteLLMLoggingBaseClass):
logging_integration=callback_name,
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args=None,
**_get_request_local_otel_init_options(callback_name),
)
if custom_logger is not None:
self.model_call_details["prompt_integration"] = model.split("/")[0]
@ -4081,11 +4100,25 @@ def set_callbacks(callback_list, function_id=None):
raise e
def _get_request_local_otel_init_options(callback_name: str) -> dict[str, bool]:
"""Prevent a request-local generic OTel logger from becoming proxy-global."""
if callback_name != "otel":
return {} # mutable-ok: factory accepts a mutable keyword mapping
return { # mutable-ok: factory accepts a mutable keyword mapping
"register_dashboard_provenance": False,
"register_on_proxy": False,
"cache_in_memory": False,
}
def _init_custom_logger_compatible_class(
logging_integration: _custom_logger_compatible_callbacks_literal,
internal_usage_cache: DualCache | None,
llm_router: Any | None, # expect litellm.Router, but typing errors due to circular import
custom_logger_init_args: dict | None = {},
register_dashboard_provenance: bool = True,
register_on_proxy: bool = True,
cache_in_memory: bool = True,
) -> CustomLogger | None:
"""
Initialize a custom logger compatible class
@ -4093,7 +4126,13 @@ def _init_custom_logger_compatible_class(
try:
custom_logger_init_args = custom_logger_init_args or {}
if logging_integration == "agentops": # Add AgentOps initialization
_v2 = _maybe_construct_otel_v2("agentops", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"agentops",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
for callback in _in_memory_loggers:
@ -4266,7 +4305,13 @@ def _init_custom_logger_compatible_class(
_in_memory_loggers.append(_opik_logger)
return _opik_logger
elif logging_integration == "arize":
_v2 = _maybe_construct_otel_v2("arize", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"arize",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
from litellm.integrations.opentelemetry import (
@ -4291,11 +4336,22 @@ def _init_custom_logger_compatible_class(
for callback in _in_memory_loggers:
if isinstance(callback, ArizeLogger) and callback.callback_name == "arize":
return callback
_arize_otel_logger: Final = ArizeLogger(config=otel_config, callback_name="arize")
_in_memory_loggers.append(_arize_otel_logger)
_arize_otel_logger: Final = ArizeLogger(
config=otel_config,
callback_name="arize",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_arize_otel_logger)
return _arize_otel_logger
elif logging_integration == "arize_phoenix":
_v2 = _maybe_construct_otel_v2("arize_phoenix", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"arize_phoenix",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
from litellm.integrations.opentelemetry import (
@ -4317,11 +4373,22 @@ def _init_custom_logger_compatible_class(
for callback in _in_memory_loggers:
if isinstance(callback, ArizePhoenixLogger) and callback.callback_name == "arize_phoenix":
return callback
_arize_phoenix_otel_logger: Final = ArizePhoenixLogger(config=otel_config, callback_name="arize_phoenix")
_in_memory_loggers.append(_arize_phoenix_otel_logger)
_arize_phoenix_otel_logger: Final = ArizePhoenixLogger(
config=otel_config,
callback_name="arize_phoenix",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_arize_phoenix_otel_logger)
return _arize_phoenix_otel_logger
elif logging_integration == "levo":
_v2 = _maybe_construct_otel_v2("levo", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"levo",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
from litellm.integrations.levo.levo import LevoLogger
@ -4342,8 +4409,13 @@ def _init_custom_logger_compatible_class(
if isinstance(callback, LevoLogger) and callback.callback_name == "levo":
return callback
_levo_otel_logger: Final = LevoLogger(config=otel_config, callback_name="levo")
_in_memory_loggers.append(_levo_otel_logger)
_levo_otel_logger: Final = LevoLogger(
config=otel_config,
callback_name="levo",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_levo_otel_logger)
return _levo_otel_logger
elif logging_integration == "otel":
# Gate the new typed V2 adapter behind LITELLM_OTEL_V2. When off,
@ -4357,29 +4429,51 @@ def _init_custom_logger_compatible_class(
from litellm.integrations.otel.logger import OpenTelemetryV2
for callback in _in_memory_loggers:
if type(callback) is OpenTelemetryV2:
if type(callback) is OpenTelemetryV2 and getattr(callback, "callback_name", None) == "otel":
if register_dashboard_provenance:
_register_dashboard_callback(callback, logging_integration)
return callback
otel_settings: Final = _get_custom_logger_settings_from_proxy_server(callback_name=logging_integration)
otel_settings.setdefault("callback_name", "otel")
otel_settings.pop("register_on_proxy", None)
otel_logger_v2: Final = OpenTelemetryV2(
**_get_custom_logger_settings_from_proxy_server(callback_name=logging_integration)
register_on_proxy=register_on_proxy,
**otel_settings,
)
_in_memory_loggers.append(otel_logger_v2)
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
if register_dashboard_provenance:
_register_dashboard_callback(otel_logger_v2, logging_integration)
if cache_in_memory:
_in_memory_loggers.append(otel_logger_v2)
if register_on_proxy:
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
return otel_logger_v2
from litellm.integrations.opentelemetry import OpenTelemetry
for callback in _in_memory_loggers:
if type(callback) is OpenTelemetry:
if type(callback) is OpenTelemetry and getattr(callback, "callback_name", None) == "otel":
if register_dashboard_provenance:
_register_dashboard_callback(callback, logging_integration)
return callback
otel_logger: Final = OpenTelemetry(
**_get_custom_logger_settings_from_proxy_server(callback_name=logging_integration)
legacy_otel_settings: Final = _get_custom_logger_settings_from_proxy_server(
callback_name=logging_integration
)
_in_memory_loggers.append(otel_logger)
legacy_otel_settings.setdefault("callback_name", "otel")
legacy_otel_settings.pop("register_on_proxy", None)
otel_logger: Final = OpenTelemetry(
register_on_proxy=register_on_proxy,
**legacy_otel_settings,
)
if register_dashboard_provenance:
_register_dashboard_callback(otel_logger, logging_integration)
if cache_in_memory:
_in_memory_loggers.append(otel_logger)
# Auto-initialize Arize Phoenix if Phoenix env vars are configured
# This allows users to get nested traces in both OTEL and Phoenix
# by only specifying "otel" in callbacks
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
if register_on_proxy:
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
return otel_logger
@ -4455,8 +4549,12 @@ def _init_custom_logger_compatible_class(
# Use exact type check to avoid matching ArizePhoenixLogger (subclass)
if type(callback) is OpenTelemetry:
return callback
_otel_logger = OpenTelemetry(config=otel_config)
_in_memory_loggers.append(_otel_logger)
_otel_logger = OpenTelemetry(
config=otel_config,
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_otel_logger)
return _otel_logger
elif logging_integration == "dynamic_rate_limiter":
from litellm.proxy.hooks.dynamic_rate_limiter import (
@ -4497,7 +4595,13 @@ def _init_custom_logger_compatible_class(
elif logging_integration == "langtrace":
if "LANGTRACE_API_KEY" not in os.environ:
raise ValueError("LANGTRACE_API_KEY not found in environment variables")
_v2 = _maybe_construct_otel_v2("langtrace", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"langtrace",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
@ -4514,8 +4618,13 @@ def _init_custom_logger_compatible_class(
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetry) and callback.callback_name == "langtrace":
return callback
_otel_logger = OpenTelemetry(config=otel_config, callback_name="langtrace")
_in_memory_loggers.append(_otel_logger)
_otel_logger = OpenTelemetry(
config=otel_config,
callback_name="langtrace",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_otel_logger)
return _otel_logger
elif logging_integration == "mlflow":
@ -4535,7 +4644,13 @@ def _init_custom_logger_compatible_class(
_in_memory_loggers.append(langfuse_logger)
return langfuse_logger
elif logging_integration == "langfuse_otel":
_v2 = _maybe_construct_otel_v2("langfuse_otel", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"langfuse_otel",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
@ -4545,11 +4660,22 @@ def _init_custom_logger_compatible_class(
return callback
# Allow LangfuseOtelLogger to initialize its own config safely
# This prevents startup crashes if LANGFUSE keys are not in env (e.g. for dynamic usage)
_otel_logger = LangfuseOtelLogger(config=None, callback_name="langfuse_otel")
_in_memory_loggers.append(_otel_logger)
_otel_logger = LangfuseOtelLogger(
config=None,
callback_name="langfuse_otel",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_otel_logger)
return _otel_logger
elif logging_integration == "weave_otel":
_v2 = _maybe_construct_otel_v2("weave_otel", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"weave_otel",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
from litellm.integrations.opentelemetry import OpenTelemetryConfig
@ -4569,8 +4695,13 @@ def _init_custom_logger_compatible_class(
for callback in _in_memory_loggers:
if isinstance(callback, WeaveOtelLogger) and callback.callback_name == "weave_otel":
return callback
_otel_logger = WeaveOtelLogger(config=otel_config, callback_name="weave_otel")
_in_memory_loggers.append(_otel_logger)
_otel_logger = WeaveOtelLogger(
config=otel_config,
callback_name="weave_otel",
register_on_proxy=register_on_proxy,
)
if cache_in_memory:
_in_memory_loggers.append(_otel_logger)
return _otel_logger
elif logging_integration == "pagerduty":
for callback in _in_memory_loggers:
@ -4696,7 +4827,13 @@ def _init_custom_logger_compatible_class(
in_memory_dynamic_logger_cache=in_memory_dynamic_logger_cache,
)
_v2 = _maybe_construct_otel_v2("newrelic", _in_memory_loggers)
_v2 = _maybe_construct_otel_v2(
"newrelic",
_in_memory_loggers,
register_dashboard_provenance=register_dashboard_provenance,
register_on_proxy=register_on_proxy,
cache_in_memory=cache_in_memory,
)
if _v2 is not None:
return _v2
for callback in _in_memory_loggers:
@ -4712,7 +4849,28 @@ def _init_custom_logger_compatible_class(
return None
def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[CustomLogger]) -> "OpenTelemetryV2 | None":
def promote_otel_callback_to_global(callback: CustomLogger) -> None:
"""Activate and retain a generic OTel logger after global callback promotion."""
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.logger import OpenTelemetryV2
if type(callback) not in (OpenTelemetry, OpenTelemetryV2):
return
if getattr(callback, "callback_name", None) != "otel":
return
callback._init_otel_logger_on_litellm_proxy()
if not any(cached_callback is callback for cached_callback in _in_memory_loggers):
_in_memory_loggers.append(callback)
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
def _maybe_construct_otel_v2(
callback_name: str,
_in_memory_loggers: list[CustomLogger],
register_dashboard_provenance: bool = True,
register_on_proxy: bool = True,
cache_in_memory: bool = True,
) -> "OpenTelemetryV2 | None":
"""If ``LITELLM_OTEL_V2`` is on, build (or reuse) a single ``OpenTelemetryV2``
instance configured via the preset for ``callback_name``.
@ -4731,6 +4889,8 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
return None
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetryV2) and getattr(callback, "callback_name", None) == callback_name:
if register_dashboard_provenance:
_register_dashboard_callback(callback, callback_name)
return callback
try:
config: Final = preset_fn()
@ -4738,8 +4898,15 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
# If env vars are missing or the preset raises, defer to the legacy path
# so customers get the same error story they had before V2 landed.
return None
v2_logger: Final = OpenTelemetryV2(config=config, callback_name=callback_name)
_in_memory_loggers.append(v2_logger)
v2_logger: Final = OpenTelemetryV2(
config=config,
callback_name=callback_name,
register_on_proxy=register_on_proxy,
)
if register_dashboard_provenance:
_register_dashboard_callback(v2_logger, callback_name)
if cache_in_memory:
_in_memory_loggers.append(v2_logger)
return v2_logger

View file

@ -1,5 +1,8 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Final
import sys
import weakref
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, NamedTuple
import litellm
from litellm._logging import verbose_logger
@ -17,6 +20,172 @@ else:
_generic_api_logger_cache: Final[dict[str, GenericAPILogger]] = {}
class _DashboardCallbackRegistration(NamedTuple):
callback_name: str
callback_type: str
_DASHBOARD_CALLBACK_ALIASES: Final[Mapping[str, str]] = MappingProxyType(
{
"arize": "arize",
"aws_sqs": "sqs",
"azure_sentinel": "azure_sentinel",
"braintrust": "braintrust",
"custom_callback_api": "generic_api",
"datadog": "datadog",
"datadog_cost_management": "datadog_cost_management",
"datadog_metrics": "datadog_metrics",
"galileo": "galileo",
"generic_api": "generic_api",
"lago": "lago",
"langfuse": "langfuse",
"langfuse_otel": "langfuse_otel",
"langsmith": "langsmith",
"newrelic": "newrelic",
"openmeter": "openmeter",
"opentelemetry": "otel",
"otel": "otel",
"s3": "s3",
"s3_v2": "s3",
"sqs": "sqs",
"traceloop": "traceloop",
}
)
def get_dashboard_callback_name(callback_name: str) -> str | None:
"""Return the dashboard identity for a supported callback name."""
return _DASHBOARD_CALLBACK_ALIASES.get(callback_name.lower())
def is_generic_api_callback(
callback_name: str,
callback_settings: Mapping[str, object] | None = None,
) -> bool:
"""Return whether a configured callback resolves to Generic API."""
if get_dashboard_callback_name(callback_name) == "generic_api":
return True
callback_settings_to_use: Final = callback_settings if callback_settings is not None else litellm.callback_settings
callback_config: Final = callback_settings_to_use.get(callback_name)
if isinstance(callback_config, dict) and callback_config.get("callback_type") == "generic_api":
return True
from litellm.integrations.generic_api.generic_api_callback import (
is_callback_compatible,
)
return is_callback_compatible(callback_name)
class _DashboardCallbackProvenance(NamedTuple):
callback_ref: weakref.ReferenceType[CustomLogger]
callback_names: tuple[str, ...]
_DASHBOARD_CALLBACK_PROVENANCE: Final[
dict[int, _DashboardCallbackProvenance]
] = {} # mutable-ok: weak-reference callbacks remove their collected logger entries
_AMBIGUOUS_DASHBOARD_CALLBACK_TYPES: Final[Mapping[tuple[str, str], str]] = MappingProxyType(
{
("litellm.integrations.opentelemetry", "OpenTelemetry"): "otel",
("litellm.integrations.otel.logger", "OpenTelemetryV2"): "otel",
}
)
_SUPPORTED_DASHBOARD_CALLBACK_TYPES: Final[Mapping[tuple[str, str], str]] = MappingProxyType(
{
("litellm.integrations.arize.arize", "ArizeLogger"): "arize",
("litellm.integrations.azure_sentinel.azure_sentinel", "AzureSentinelLogger"): "azure_sentinel",
("litellm.integrations.braintrust_logging", "BraintrustLogger"): "braintrust",
("litellm.integrations.generic_api.generic_api_callback", "GenericAPILogger"): "generic_api",
("litellm.integrations.datadog.datadog", "DataDogLogger"): "datadog",
(
"litellm.integrations.datadog.datadog_cost_management",
"DatadogCostManagementLogger",
): "datadog_cost_management",
("litellm.integrations.datadog.datadog_metrics", "DatadogMetricsLogger"): "datadog_metrics",
("litellm.integrations.galileo", "GalileoObserve"): "galileo",
("litellm.integrations.lago", "LagoLogger"): "lago",
("litellm.integrations.langfuse.langfuse_otel", "LangfuseOtelLogger"): "langfuse_otel",
("litellm.integrations.langfuse.langfuse_prompt_management", "LangfusePromptManagement"): "langfuse",
("litellm.integrations.langsmith", "LangsmithLogger"): "langsmith",
("litellm.integrations.newrelic.newrelic", "NewRelicLogger"): "newrelic",
("litellm.integrations.openmeter", "OpenMeterLogger"): "openmeter",
("litellm.integrations.s3_v2", "S3Logger"): "s3",
("litellm.integrations.sqs", "SQSLogger"): "sqs",
}
)
def _get_dashboard_callback_type_name(
callback: CustomLogger,
callback_types: Mapping[tuple[str, str], str],
) -> str | None:
callback_type: Final = type(callback)
callback_key: Final = (callback_type.__module__, callback_type.__name__)
dashboard_callback_name: Final = callback_types.get(callback_key)
if dashboard_callback_name is None:
return None
callback_module: Final = sys.modules.get(callback_type.__module__)
if callback_module is None or getattr(callback_module, callback_type.__name__, None) is not callback_type:
return None
return dashboard_callback_name
def register_dashboard_callback(callback: CustomLogger, callback_name: str) -> None:
"""Record the dashboard identity of a factory-created OTel logger."""
dashboard_callback_name: Final = get_dashboard_callback_name(callback_name)
if (
dashboard_callback_name is None
or _get_dashboard_callback_type_name(callback, _AMBIGUOUS_DASHBOARD_CALLBACK_TYPES) is None
):
return
callback_id: Final = id(callback)
registered_callback: Final = _DASHBOARD_CALLBACK_PROVENANCE.get(callback_id)
if registered_callback is not None and registered_callback.callback_ref() is callback:
if dashboard_callback_name not in registered_callback.callback_names:
_DASHBOARD_CALLBACK_PROVENANCE[callback_id] = _DashboardCallbackProvenance(
registered_callback.callback_ref,
registered_callback.callback_names + (dashboard_callback_name,),
)
return
def _remove_callback(reference: weakref.ReferenceType[CustomLogger]) -> None:
registered_callback: Final = _DASHBOARD_CALLBACK_PROVENANCE.get(callback_id)
if registered_callback is not None and registered_callback.callback_ref is reference:
_DASHBOARD_CALLBACK_PROVENANCE.pop(callback_id, None)
try:
_DASHBOARD_CALLBACK_PROVENANCE[callback_id] = _DashboardCallbackProvenance(
weakref.ref(callback, _remove_callback),
(dashboard_callback_name,),
)
except TypeError:
return
def _get_registered_dashboard_callback_names(callback: CustomLogger) -> tuple[str, ...]:
registered_callback: Final = _DASHBOARD_CALLBACK_PROVENANCE.get(id(callback))
if registered_callback is None or registered_callback.callback_ref() is not callback:
return ()
return registered_callback.callback_names
def _get_dashboard_callback_registry_callbacks() -> tuple[CustomLogger | Callable | str, ...]:
return tuple(
callback
for callback_registry in (
litellm.success_callback,
getattr(litellm, "_async_success_callback", ()),
litellm.failure_callback,
getattr(litellm, "_async_failure_callback", ()),
litellm.callbacks,
)
for callback in callback_registry
)
class LoggingCallbackManager:
"""
A centralized class that allows easy add / remove callbacks for litellm.
@ -244,6 +413,18 @@ class LoggingCallbackManager:
return callback
def reset_dashboard_callback_registrations(self) -> None:
"""Drop provenance for callbacks no longer active in dashboard registries."""
active_callbacks: Final = _get_dashboard_callback_registry_callbacks()
stale_callback_ids: Final = tuple(
callback_id
for callback_id, registration in _DASHBOARD_CALLBACK_PROVENANCE.items()
if registration.callback_ref() is None
or not any(registration.callback_ref() is callback for callback in active_callbacks)
)
for callback_id in stale_callback_ids:
_DASHBOARD_CALLBACK_PROVENANCE.pop(callback_id, None)
def _safe_add_callback_to_list(
self,
callback: CustomLogger | Callable | str,
@ -340,6 +521,7 @@ class LoggingCallbackManager:
litellm._async_success_callback = []
litellm._async_failure_callback = []
litellm.callbacks = []
self.reset_dashboard_callback_registrations()
def _get_all_callbacks(self) -> list[CustomLogger | Callable | str]:
"""
@ -441,6 +623,80 @@ class LoggingCallbackManager:
return result
def get_dashboard_callback_registrations(self) -> tuple[_DashboardCallbackRegistration, ...]:
"""Return active callbacks recognized by the Logging & Alerts dashboard."""
self.reset_dashboard_callback_registrations()
success_callbacks: Final = tuple(litellm.success_callback) + tuple(
getattr(litellm, "_async_success_callback", ())
)
failure_callbacks: Final = tuple(litellm.failure_callback) + tuple(
getattr(litellm, "_async_failure_callback", ())
)
registrations: Final = tuple(
registration
for callback_type, callbacks in (
("success", success_callbacks),
("failure", failure_callbacks),
("success_and_failure", tuple(litellm.callbacks)),
)
for callback in callbacks
for registration in self._get_dashboard_callback_registration(callback, callback_type)
)
callback_names: Final = tuple(
registration.callback_name
for registration_index, registration in enumerate(registrations)
if registration.callback_name
not in tuple(prior.callback_name for prior in registrations[:registration_index])
)
return tuple(
_DashboardCallbackRegistration(callback_name, callback_type)
for callback_name in callback_names
for callback_type in self._get_dashboard_callback_types(callback_name, registrations)
)
@staticmethod
def _get_dashboard_callback_types(
callback_name: str,
registrations: tuple[_DashboardCallbackRegistration, ...],
) -> tuple[str, ...]:
callback_types: Final = tuple(
registration.callback_type for registration in registrations if registration.callback_name == callback_name
)
if "success_and_failure" in callback_types or ("success" in callback_types and "failure" in callback_types):
return ("success_and_failure",)
if "success" in callback_types:
return ("success",)
if "failure" in callback_types:
return ("failure",)
return ()
@staticmethod
def _get_dashboard_callback_registration(
callback: CustomLogger | Callable | str,
callback_type: str,
) -> tuple[_DashboardCallbackRegistration, ...]:
return tuple(
_DashboardCallbackRegistration(callback_name, callback_type)
for callback_name in LoggingCallbackManager._get_dashboard_callback_names(callback)
)
@staticmethod
def _get_dashboard_callback_names(callback: CustomLogger | Callable | str) -> tuple[str, ...]:
if isinstance(callback, str):
callback_name: Final = get_dashboard_callback_name(callback)
return (callback_name,) if callback_name is not None else ()
if not isinstance(callback, CustomLogger):
return ()
dashboard_callback_name: Final = _get_dashboard_callback_type_name(
callback, _SUPPORTED_DASHBOARD_CALLBACK_TYPES
)
if dashboard_callback_name is not None:
return (dashboard_callback_name,)
if _get_dashboard_callback_type_name(callback, _AMBIGUOUS_DASHBOARD_CALLBACK_TYPES) is not None:
return _get_registered_dashboard_callback_names(callback)
return ()
def _get_callback_string(self, callback: CustomLogger | Callable | str) -> str:
from litellm.litellm_core_utils.custom_logger_registry import (
CustomLoggerRegistry,

View file

@ -3462,6 +3462,45 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
],
)
arize: CallbackOnUI = CallbackOnUI(
litellm_callback_name="arize",
ui_callback_name="Arize",
litellm_callback_params=[ # mutable-ok: UI callback parameter order is user-facing
"ARIZE_SPACE_ID",
"ARIZE_SPACE_KEY",
"ARIZE_API_KEY",
"ARIZE_PROJECT_NAME",
"ARIZE_ENDPOINT",
"ARIZE_HTTP_ENDPOINT",
],
)
datadog_metrics: CallbackOnUI = CallbackOnUI(
litellm_callback_name="datadog_metrics",
ui_callback_name="Datadog Metrics",
litellm_callback_params=[ # mutable-ok: UI callback parameter order is user-facing
"DD_API_KEY",
"DD_APP_KEY",
"DD_SITE",
],
)
datadog_cost_management: CallbackOnUI = CallbackOnUI(
litellm_callback_name="datadog_cost_management",
ui_callback_name="Datadog Cost Management",
litellm_callback_params=[ # mutable-ok: UI callback parameter order is user-facing
"DD_API_KEY",
"DD_APP_KEY",
"DD_SITE",
],
)
sqs: CallbackOnUI = CallbackOnUI(
litellm_callback_name="sqs",
ui_callback_name="SQS",
litellm_callback_params=[], # mutable-ok: CallbackOnUI requires a mutable list
)
openmeter: CallbackOnUI = CallbackOnUI(
litellm_callback_name="openmeter",
ui_callback_name="OpenMeter",

View file

@ -85,6 +85,10 @@ def _resolve_audit_log_callback(name: str) -> CustomLogger | None:
logging_integration=name,
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args=None,
register_dashboard_provenance=False,
register_on_proxy=False,
cache_in_memory=False,
)
if instance is not None:

View file

@ -64,10 +64,15 @@ from litellm.constants import (
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
)
from litellm.litellm_core_utils.logging_callback_manager import (
get_dashboard_callback_name,
is_generic_api_callback,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.proxy._types import (
UI_TEAM_ID,
AllCallbacks,
CallbackDelete,
CallInfo,
CommonProxyErrors,
@ -16356,6 +16361,55 @@ def _apply_callback_role_gate(entries: list, is_full_admin: bool) -> list:
return [{**entry, "variables": _redact_callback_env_vars(entry.get("variables") or {})} for entry in entries]
def _get_configured_dashboard_callback_name(
callback_name: str,
callback_settings: object,
) -> str | None:
dashboard_callback_name: Final = get_dashboard_callback_name(callback_name)
if dashboard_callback_name is not None:
return dashboard_callback_name
if is_generic_api_callback(
callback_name,
callback_settings
if isinstance(callback_settings, Mapping)
else {}, # mutable-ok: utility expects mapping-compatible settings
):
return "generic_api"
return None
def _get_read_only_runtime_callback_row(
callback_name: str,
callback_type: str,
environment_variables: dict,
) -> dict:
callback_row: Final = process_callback(
callback_name,
callback_type,
environment_variables,
)
return callback_row | {"read_only": True} # mutable-ok: HTTP response rows are mutable dictionaries
def _get_runtime_callback_rows(
configured_callback_registrations: frozenset[tuple[str, str]],
environment_variables: dict,
) -> tuple[dict, ...]:
return tuple(
_get_read_only_runtime_callback_row(callback_name, callback_type, environment_variables)
for callback_name, callback_type in litellm.logging_callback_manager.get_dashboard_callback_registrations()
if not (
(callback_name, "success_and_failure") in configured_callback_registrations
or (callback_name, callback_type) in configured_callback_registrations
or (
callback_type == "success_and_failure"
and (callback_name, "success") in configured_callback_registrations
and (callback_name, "failure") in configured_callback_registrations
)
)
)
class _AlertingDestinationEntry(TypedDict):
name: ReadOnly[str]
variables: ReadOnly[Mapping[str, str | None]]
@ -16975,6 +17029,21 @@ async def get_config(
for _callback in _success_and_failure_callbacks:
_data_to_return.append(process_callback(_callback, "success_and_failure", environment_variables))
callback_settings: Final = config_data.get("callback_settings")
configured_callback_registrations: Final = frozenset(
(canonical_callback_name, callback_type)
for callback_type, callback_group in (
("success", _success_callbacks),
("failure", _failure_callbacks),
("success_and_failure", _success_and_failure_callbacks),
)
for callback in callback_group
if isinstance(callback, str)
for canonical_callback_name in (_get_configured_dashboard_callback_name(callback, callback_settings),)
if canonical_callback_name is not None
)
_data_to_return.extend(_get_runtime_callback_rows(configured_callback_registrations, environment_variables))
_data_to_return = _apply_callback_role_gate(_data_to_return, is_full_admin)
# Check if slack alerting is on

View file

@ -859,18 +859,39 @@ def function_setup(
all_callbacks: Final = get_dynamic_callbacks(dynamic_callbacks=dynamic_callbacks)
if len(all_callbacks) > 0:
from litellm.litellm_core_utils.litellm_logging import (
promote_otel_callback_to_global,
)
from litellm.litellm_core_utils.logging_callback_manager import (
register_dashboard_callback,
)
for callback in all_callbacks:
# check if callback is a string - e.g. "lago", "openmeter"
if isinstance(callback, str):
callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class(
callback,
internal_usage_cache=None,
llm_router=None,
)
dashboard_callback_name: str | None = callback if callback == "otel" else None
if dashboard_callback_name is not None:
callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class(
callback,
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args=None,
register_dashboard_provenance=False,
register_on_proxy=False,
cache_in_memory=False,
)
else:
callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class(
callback,
internal_usage_cache=None,
llm_router=None,
)
if callback is None or any(
type(cb) is type(callback) for cb in litellm._async_success_callback
): # don't double add a callback
continue
else:
dashboard_callback_name = None
if callback not in litellm.input_callback:
litellm.input_callback.append(callback)
if callback not in litellm.success_callback:
@ -881,6 +902,9 @@ def function_setup(
litellm.logging_callback_manager.add_litellm_async_success_callback(callback)
if callback not in litellm._async_failure_callback:
litellm.logging_callback_manager.add_litellm_async_failure_callback(callback)
if dashboard_callback_name is not None:
promote_otel_callback_to_global(callback)
register_dashboard_callback(callback, dashboard_callback_name)
print_verbose(f"Initialized litellm callbacks, Async Success Callbacks: {litellm._async_success_callback}")
if (

View file

@ -1,17 +1,17 @@
import json
import os
import time
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
# Test fixtures
@ -33,6 +33,493 @@ def mock_custom_logger():
# Test cases
def test_dashboard_callback_names_cover_callback_config_catalogue():
from pathlib import Path
from litellm.litellm_core_utils.logging_callback_manager import (
get_dashboard_callback_name,
)
callback_config_path = Path(__file__).parents[2] / "litellm/integrations/callback_configs.json"
with callback_config_path.open() as config_file:
callback_configs = json.load(config_file)
assert all(get_dashboard_callback_name(callback_config["id"]) is not None for callback_config in callback_configs)
def test_dashboard_callback_names_cover_dashboard_callback_catalogue():
from litellm.litellm_core_utils.logging_callback_manager import (
get_dashboard_callback_name,
)
from litellm.proxy._types import AllCallbacks
callback_names = {callback["litellm_callback_name"] for callback in AllCallbacks().model_dump().values()}
assert all(get_dashboard_callback_name(callback_name) is not None for callback_name in callback_names)
def test_dashboard_callback_inventory_includes_only_public_callbacks(callback_manager, monkeypatch):
class UnhashableCustomLogger(CustomLogger):
__hash__ = None
def __eq__(self, other):
return self is other
def custom_callback(*args, **kwargs):
pass
unhashable_callback = UnhashableCustomLogger()
monkeypatch.setattr(
litellm,
"success_callback",
["opentelemetry", "s3_v2", "custom_callback_api", unhashable_callback, custom_callback],
)
monkeypatch.setattr(litellm, "_async_success_callback", ["langsmith"])
monkeypatch.setattr(litellm, "failure_callback", ["aws_sqs", "langsmith"])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", ["langfuse_otel", "_PROXY_VirtualKeyModelMaxBudgetLimiter"])
assert callback_manager.get_dashboard_callback_registrations() == (
("otel", "success"),
("s3", "success"),
("generic_api", "success"),
("langsmith", "success_and_failure"),
("sqs", "failure"),
("langfuse_otel", "success_and_failure"),
)
def test_dashboard_callback_inventory_recognizes_azure_sentinel_and_traceloop(
callback_manager,
monkeypatch,
):
from litellm.integrations.azure_sentinel.azure_sentinel import AzureSentinelLogger
# azure_sentinel is promoted to a logger object by the factory; traceloop is a
# legacy string callback the success handler dispatches by name.
monkeypatch.setattr(litellm, "success_callback", [object.__new__(AzureSentinelLogger), "traceloop"])
assert callback_manager.get_dashboard_callback_registrations() == (
("azure_sentinel", "success"),
("traceloop", "success"),
)
def test_dashboard_callback_inventory_rejects_callback_name_spoof(callback_manager, monkeypatch):
class SpoofedCallback(CustomLogger):
callback_name = "s3_v2"
monkeypatch.setattr(litellm, "success_callback", [SpoofedCallback()])
assert callback_manager.get_dashboard_callback_registrations() == ()
def test_dashboard_callback_inventory_rejects_spoofed_trusted_subclasses(
callback_manager,
monkeypatch,
):
from litellm.integrations.arize.arize import ArizeLogger
from litellm.integrations.generic_api.generic_api_callback import (
GenericAPILogger,
)
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from litellm.integrations.otel.logger import OpenTelemetryV2
spoofed_callback_types = tuple(
type(
callback_name,
(callback_type,),
{},
)
for callback_name, callback_type in (
("SpoofedArizeLogger", ArizeLogger),
("SpoofedGenericAPILogger", GenericAPILogger),
("SpoofedLangfuseOtelLogger", LangfuseOtelLogger),
("SpoofedOpenTelemetry", OpenTelemetry),
("SpoofedOpenTelemetryV2", OpenTelemetryV2),
)
)
monkeypatch.setattr(
litellm, "success_callback", [object.__new__(callback_type) for callback_type in spoofed_callback_types]
)
for callback in litellm.success_callback:
callback.callback_name = "generic_api"
assert callback_manager.get_dashboard_callback_registrations() == ()
def test_dashboard_callback_inventory_rejects_unregistered_otel_callback(
callback_manager,
monkeypatch,
):
otel_callback = object.__new__(OpenTelemetry)
otel_callback.callback_name = "langfuse"
monkeypatch.setattr(litellm, "success_callback", [otel_callback])
assert callback_manager.get_dashboard_callback_registrations() == ()
@pytest.mark.asyncio
async def test_dashboard_callback_inventory_recognizes_supported_logger_types(
callback_manager,
monkeypatch,
):
from litellm.integrations.datadog.datadog_cost_management import (
DatadogCostManagementLogger,
)
from litellm.integrations.datadog.datadog_metrics import DatadogMetricsLogger
from litellm.integrations.lago import LagoLogger
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
from litellm.integrations.langsmith import LangsmithLogger
from litellm.integrations.openmeter import OpenMeterLogger
from litellm.integrations.s3_v2 import S3Logger
from litellm.integrations.sqs import SQSLogger
monkeypatch.setenv("LAGO_API_KEY", "test-key")
monkeypatch.setenv("LAGO_API_BASE", "https://example.com")
monkeypatch.setenv("LAGO_API_EVENT_CODE", "test-event")
monkeypatch.setenv("OPENMETER_API_KEY", "test-key")
monkeypatch.setattr(
litellm,
"success_callback",
[
LagoLogger(),
OpenMeterLogger(),
LangfusePromptManagement(),
LangsmithLogger(),
DatadogMetricsLogger(),
DatadogCostManagementLogger(),
S3Logger(),
SQSLogger(),
],
)
assert callback_manager.get_dashboard_callback_registrations() == (
("lago", "success"),
("openmeter", "success"),
("langfuse", "success"),
("langsmith", "success"),
("datadog_metrics", "success"),
("datadog_cost_management", "success"),
("s3", "success"),
("sqs", "success"),
)
@pytest.mark.asyncio
async def test_dashboard_callback_inventory_includes_builtin_generic_api_callback(
callback_manager,
monkeypatch,
):
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("GENERIC_LOGGER_ENDPOINT", "https://example.com/logs")
logging_module._in_memory_loggers.clear()
try:
generic_api_callback = logging_module._init_custom_logger_compatible_class(
logging_integration="generic_api",
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args={},
)
assert generic_api_callback is not None
assert generic_api_callback.callback_name is None
monkeypatch.setattr(litellm, "success_callback", [generic_api_callback])
assert callback_manager.get_dashboard_callback_registrations() == (("generic_api", "success"),)
finally:
logging_module._in_memory_loggers.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize("callback_name", [None, "logfire"])
async def test_dashboard_callback_inventory_includes_factory_created_otel_callback(
callback_manager,
monkeypatch,
callback_name,
):
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setattr(
litellm,
"callback_settings",
{"otel": {"callback_name": callback_name}},
)
logging_module._in_memory_loggers.clear()
try:
otel_callback = logging_module._init_custom_logger_compatible_class(
logging_integration="otel",
internal_usage_cache=None,
llm_router=None,
custom_logger_init_args={},
)
assert otel_callback is not None
assert otel_callback.callback_name == callback_name
monkeypatch.setattr(litellm, "success_callback", [otel_callback])
assert callback_manager.get_dashboard_callback_registrations() == (("otel", "success"),)
finally:
logging_module._in_memory_loggers.clear()
@pytest.mark.asyncio
async def test_dashboard_otel_provenance_keeps_legacy_otel_separate_from_preset(
callback_manager,
monkeypatch,
):
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("LANGTRACE_API_KEY", "test-key")
monkeypatch.setenv("LITELLM_OTEL_V2", "false")
is_otel_v2_enabled.cache_clear()
logging_module._in_memory_loggers.clear()
try:
langtrace = logging_module._init_custom_logger_compatible_class("langtrace", None, None, {})
otel = logging_module._init_custom_logger_compatible_class("otel", None, None, {})
assert otel is not langtrace
monkeypatch.setattr(litellm, "success_callback", [otel])
assert callback_manager.get_dashboard_callback_registrations() == (("otel", "success"),)
finally:
logging_module._in_memory_loggers.clear()
is_otel_v2_enabled.cache_clear()
@pytest.mark.asyncio
async def test_dashboard_otel_provenance_registered_on_v2_preset_cache_hit_after_reset(
callback_manager,
monkeypatch,
):
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
logging_module._in_memory_loggers.clear()
try:
arize = logging_module._init_custom_logger_compatible_class("arize", None, None, {})
callback_manager._reset_all_callbacks()
cached_arize = logging_module._init_custom_logger_compatible_class("arize", None, None, {})
assert cached_arize is arize
monkeypatch.setattr(litellm, "success_callback", [cached_arize])
assert callback_manager.get_dashboard_callback_registrations() == (("arize", "success"),)
finally:
logging_module._in_memory_loggers.clear()
is_otel_v2_enabled.cache_clear()
@pytest.mark.asyncio
async def test_dashboard_otel_provenance_registered_on_v2_otel_cache_hit(
callback_manager,
monkeypatch,
):
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
logging_module._in_memory_loggers.clear()
try:
arize = logging_module._init_custom_logger_compatible_class("arize", None, None, {})
otel = logging_module._init_custom_logger_compatible_class("otel", None, None, {})
assert otel is not arize
monkeypatch.setattr(litellm, "success_callback", [otel])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
assert callback_manager.get_dashboard_callback_registrations() == (("otel", "success"),)
finally:
logging_module._in_memory_loggers.clear()
is_otel_v2_enabled.cache_clear()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("otel_v2_enabled", "expected_type"),
((True, "OpenTelemetryV2"), (False, "OpenTelemetry")),
)
async def test_dynamic_otel_callback_resolution_is_request_local(
callback_manager,
monkeypatch,
otel_v2_enabled,
expected_type,
):
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("LITELLM_OTEL_V2", str(otel_v2_enabled).lower())
is_otel_v2_enabled.cache_clear()
monkeypatch.setattr(logging_module, "_in_memory_loggers", [])
monkeypatch.setattr(litellm, "input_callback", [])
monkeypatch.setattr(litellm, "service_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
request_logging = logging_module.Logging(
model="test-model",
messages=[],
stream=False,
call_type="completion",
start_time=None,
litellm_call_id="request-id",
function_id="function-id",
dynamic_success_callbacks=["otel"],
)
dynamic_callback = request_logging.dynamic_success_callbacks[0]
expected_callback_type = OpenTelemetryV2 if expected_type == "OpenTelemetryV2" else OpenTelemetry
assert type(dynamic_callback) is expected_callback_type
assert dynamic_callback.callback_name == "otel"
assert logging_module._in_memory_loggers == []
assert all(
dynamic_callback is not registered_callback
for callback_registry in (
litellm.input_callback,
litellm.service_callback,
litellm.success_callback,
litellm.failure_callback,
litellm._async_success_callback,
litellm._async_failure_callback,
litellm.callbacks,
)
for registered_callback in callback_registry
)
assert callback_manager.get_dashboard_callback_registrations() == ()
is_otel_v2_enabled.cache_clear()
@pytest.mark.parametrize(
("otel_v2_enabled", "expected_type"),
((True, "OpenTelemetryV2"), (False, "OpenTelemetry")),
)
def test_prompt_management_otel_resolution_is_request_local(
callback_manager,
monkeypatch,
otel_v2_enabled,
expected_type,
):
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
monkeypatch.setenv("LITELLM_OTEL_V2", str(otel_v2_enabled).lower())
is_otel_v2_enabled.cache_clear()
monkeypatch.setattr(logging_module, "_in_memory_loggers", [])
monkeypatch.setattr(litellm, "input_callback", [])
monkeypatch.setattr(litellm, "service_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
request_logging = logging_module.Logging(
model="otel/prompt",
messages=[],
stream=False,
call_type="completion",
start_time=None,
litellm_call_id="request-id",
function_id="function-id",
)
prompt_logger = request_logging.get_custom_logger_for_prompt_management(
model="otel/prompt",
non_default_params={},
)
expected_callback_type = OpenTelemetryV2 if expected_type == "OpenTelemetryV2" else OpenTelemetry
assert type(prompt_logger) is expected_callback_type
assert prompt_logger.callback_name == "otel"
assert logging_module._in_memory_loggers == []
assert all(
prompt_logger is not registered_callback
for callback_registry in (
litellm.input_callback,
litellm.service_callback,
litellm.success_callback,
litellm.failure_callback,
litellm._async_success_callback,
litellm._async_failure_callback,
litellm.callbacks,
)
for registered_callback in callback_registry
)
assert callback_manager.get_dashboard_callback_registrations() == ()
is_otel_v2_enabled.cache_clear()
@pytest.mark.parametrize(
("otel_v2_enabled", "expected_type"),
((True, "OpenTelemetryV2"), (False, "OpenTelemetry")),
)
def test_function_setup_promotes_cold_otel_only_after_duplicate_check(
callback_manager,
monkeypatch,
otel_v2_enabled,
expected_type,
):
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging as logging_module
from litellm.proxy import proxy_server
checker = MagicMock()
checker.is_async_callable.return_value = False
logging_object = MagicMock()
monkeypatch.setenv("LITELLM_OTEL_V2", str(otel_v2_enabled).lower())
monkeypatch.setattr(litellm.utils.litellm_utils, "get_coroutine_checker", lambda: checker)
monkeypatch.setattr(litellm.utils, "get_litellm_logging_class", lambda: logging_object)
monkeypatch.setattr(litellm.utils, "callback_list", ["already-initialized"])
monkeypatch.setattr(logging_module, "_in_memory_loggers", [])
monkeypatch.setattr(litellm, "input_callback", [])
monkeypatch.setattr(litellm, "service_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(proxy_server, "open_telemetry_logger", None)
is_otel_v2_enabled.cache_clear()
try:
litellm.utils.function_setup(
original_function="acompletion",
rules_obj=litellm.utils.Rules(),
start_time=datetime.now(),
model="gpt-4",
messages=[],
litellm_call_id="cold-global-promotion",
callbacks=["otel"],
)
otel_callback = litellm._async_success_callback[0]
expected_callback_type = OpenTelemetryV2 if expected_type == "OpenTelemetryV2" else OpenTelemetry
assert type(otel_callback) is expected_callback_type
assert otel_callback.callback_name == "otel"
assert otel_callback in logging_module._in_memory_loggers
assert otel_callback in litellm.input_callback
assert otel_callback in litellm.service_callback
assert otel_callback in litellm._async_failure_callback
assert proxy_server.open_telemetry_logger is otel_callback
assert callback_manager.get_dashboard_callback_registrations() == (("otel", "success_and_failure"),)
finally:
is_otel_v2_enabled.cache_clear()
def test_add_string_callback():
"""
Test adding a string callback to litellm.callbacks - only 1 instance of the string callback should be added
@ -54,7 +541,6 @@ def test_duplicate_langfuse_logger_test():
for _ in range(10):
langfuse_logger = LangfusePromptManagement()
manager.add_litellm_success_callback(langfuse_logger)
print("litellm.success_callback: ", litellm.success_callback)
assert len(litellm.success_callback) == 1
@ -65,24 +551,13 @@ def test_duplicate_multiple_loggers_test():
otel_logger = OpenTelemetry()
manager.add_litellm_success_callback(langfuse_logger)
manager.add_litellm_success_callback(otel_logger)
print("litellm.success_callback: ", litellm.success_callback)
assert len(litellm.success_callback) == 2
# Check exactly one instance of each logger type
langfuse_count = sum(
1
for callback in litellm.success_callback
if isinstance(callback, LangfusePromptManagement)
)
otel_count = sum(
1
for callback in litellm.success_callback
if isinstance(callback, OpenTelemetry)
)
langfuse_count = sum(1 for callback in litellm.success_callback if isinstance(callback, LangfusePromptManagement))
otel_count = sum(1 for callback in litellm.success_callback if isinstance(callback, OpenTelemetry))
assert (
langfuse_count == 1
), "Should have exactly one LangfusePromptManagement instance"
assert langfuse_count == 1, "Should have exactly one LangfusePromptManagement instance"
assert otel_count == 1, "Should have exactly one OpenTelemetry instance"
@ -237,23 +712,18 @@ async def test_slack_alerting_callback_registration(callback_manager):
when outage_alerts or region_outage_alerts are enabled
"""
from litellm.caching.caching import DualCache
from litellm.proxy.utils import ProxyLogging
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from unittest.mock import patch
from litellm.proxy.utils import ProxyLogging
# Mock the async HTTP handler
with patch(
"litellm.integrations.SlackAlerting.slack_alerting.get_async_httpx_client"
) as mock_http:
with patch("litellm.integrations.SlackAlerting.slack_alerting.get_async_httpx_client") as mock_http:
mock_http.return_value = AsyncMock()
# Create a fresh ProxyLogging instance
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
# Test 1: No callbacks should be added when alerting is None
proxy_logging.update_values(
alerting=None, alert_types=["outage_alerts", "region_outage_alerts"]
)
proxy_logging.update_values(alerting=None, alert_types=["outage_alerts", "region_outage_alerts"])
assert len(litellm.callbacks) == 0
# Test 2: Callbacks should be added when slack alerting is enabled with outage alerts
@ -263,16 +733,15 @@ async def test_slack_alerting_callback_registration(callback_manager):
# Test 3: Callbacks should be added when slack alerting is enabled with region outage alerts
callback_manager._reset_all_callbacks() # Reset callbacks
proxy_logging.update_values(
alerting=["slack"], alert_types=["region_outage_alerts"]
)
proxy_logging.update_values(alerting=["slack"], alert_types=["region_outage_alerts"])
assert len(litellm.callbacks) == 1
assert isinstance(litellm.callbacks[0], SlackAlerting)
# Test 4: No callbacks should be added for other alert types
callback_manager._reset_all_callbacks() # Reset callbacks
proxy_logging.update_values(
alerting=["slack"], alert_types=["budget_alerts"] # Some other alert type
alerting=["slack"],
alert_types=["budget_alerts"], # Some other alert type
)
assert len(litellm.callbacks) == 0
@ -282,9 +751,7 @@ async def test_slack_alerting_callback_registration(callback_manager):
assert len(litellm.callbacks) == 1 # Regular callback for outage alerts
assert isinstance(litellm.callbacks[0], SlackAlerting)
# response_taking_too_long_callback is async, so it should be in the async success callback list
response_taking_too_long_callback = (
proxy_logging.slack_alerting_instance.response_taking_too_long_callback
)
response_taking_too_long_callback = proxy_logging.slack_alerting_instance.response_taking_too_long_callback
assert len(litellm._async_success_callback) == 1
assert litellm._async_success_callback[0] == response_taking_too_long_callback
@ -305,28 +772,18 @@ async def test_generic_api_compatible_callbacks_json():
with patch.dict(os.environ, {"SUMOLOGIC_WEBHOOK_URL": test_sumologic_url}):
# Test that sumologic callback is recognized from JSON file
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
"sumologic"
)
result = LoggingCallbackManager._add_custom_callback_generic_api_str("sumologic")
# Verify a GenericAPILogger instance is returned
assert isinstance(
result, GenericAPILogger
), "Should return GenericAPILogger instance for sumologic callback"
assert isinstance(result, GenericAPILogger), "Should return GenericAPILogger instance for sumologic callback"
# Verify the endpoint is correctly loaded from environment variable
assert (
result.endpoint == test_sumologic_url
), f"Endpoint should be {test_sumologic_url}"
assert result.endpoint == test_sumologic_url, f"Endpoint should be {test_sumologic_url}"
# Verify headers only contain Content-Type (no Authorization for SumoLogic)
assert "Content-Type" in result.headers, "Should have Content-Type header"
assert (
result.headers["Content-Type"] == "application/json"
), "Content-Type should be application/json"
assert (
"Authorization" not in result.headers
), "Should not have Authorization header for SumoLogic"
assert result.headers["Content-Type"] == "application/json", "Content-Type should be application/json"
assert "Authorization" not in result.headers, "Should not have Authorization header for SumoLogic"
@pytest.mark.asyncio
@ -349,28 +806,20 @@ async def test_generic_api_compatible_callbacks_json_rubrik():
result = LoggingCallbackManager._add_custom_callback_generic_api_str("rubrik")
# Verify a GenericAPILogger instance is returned
assert isinstance(
result, GenericAPILogger
), "Should return GenericAPILogger instance for rubrik callback"
assert isinstance(result, GenericAPILogger), "Should return GenericAPILogger instance for rubrik callback"
# Verify the endpoint is correctly loaded
assert (
result.endpoint == test_rubrik_url
), f"Endpoint should be {test_rubrik_url}"
assert result.endpoint == test_rubrik_url, f"Endpoint should be {test_rubrik_url}"
# Verify headers include Authorization with Bearer token
assert "Content-Type" in result.headers, "Should have Content-Type header"
assert (
"Authorization" in result.headers
), "Should have Authorization header for Rubrik"
assert (
result.headers["Authorization"] == f"Bearer {test_rubrik_api_key}"
), "Authorization should have correct API key"
assert "Authorization" in result.headers, "Should have Authorization header for Rubrik"
assert result.headers["Authorization"] == f"Bearer {test_rubrik_api_key}", (
"Authorization should have correct API key"
)
# Verify event_types filter (rubrik only logs success events)
assert result.event_types == [
"llm_api_success"
], "Rubrik should only log success events"
assert result.event_types == ["llm_api_success"], "Rubrik should only log success events"
def test_generic_api_compatible_callbacks_json_unknown_callback():
@ -378,9 +827,7 @@ def test_generic_api_compatible_callbacks_json_unknown_callback():
Test that unknown callbacks (not in JSON or callback_settings) are returned unchanged
"""
# Test with a callback that doesn't exist in the JSON file
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
"unknown_callback"
)
result = LoggingCallbackManager._add_custom_callback_generic_api_str("unknown_callback")
# Should return the string unchanged
assert result == "unknown_callback", "Unknown callback should be returned as-is"
@ -409,9 +856,7 @@ async def test_generic_api_callback_settings_retry_config():
}
try:
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
callback_name
)
result = LoggingCallbackManager._add_custom_callback_generic_api_str(callback_name)
assert isinstance(result, GenericAPILogger)
assert result.endpoint == "https://example.com/api/logs"

View file

@ -18,6 +18,7 @@ from litellm.proxy.management_helpers.audit_logs import (
_audit_log_task_done_callback,
_build_audit_log_payload,
_dispatch_audit_log_to_callbacks,
_resolve_audit_log_callback,
create_audit_log_for_update,
is_audit_logging_enabled,
)
@ -26,8 +27,13 @@ from litellm.types.utils import StandardAuditLogPayload
@pytest.fixture(autouse=True)
def reset_audit_log_callbacks(monkeypatch: pytest.MonkeyPatch) -> None:
"""Every test starts with no audit log callbacks registered."""
"""Every test starts with no audit log callbacks or cached resolvers."""
from litellm.proxy.management_helpers.audit_logs import reset_audit_log_callback_cache
monkeypatch.setattr(litellm, "audit_log_callbacks", [])
reset_audit_log_callback_cache()
yield
reset_audit_log_callback_cache()
def _make_audit_log(
@ -47,6 +53,58 @@ def _make_audit_log(
)
@pytest.mark.parametrize(
("otel_v2_enabled", "expected_type"),
((True, "OpenTelemetryV2"), (False, "OpenTelemetry")),
)
def test_resolving_an_audit_callback_is_local_and_not_dashboard_visible(
monkeypatch: pytest.MonkeyPatch,
otel_v2_enabled: bool,
expected_type: str,
):
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils import litellm_logging
from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
manager = LoggingCallbackManager()
monkeypatch.setenv("LITELLM_OTEL_V2", str(otel_v2_enabled).lower())
is_otel_v2_enabled.cache_clear()
monkeypatch.setattr(litellm_logging, "_in_memory_loggers", [])
monkeypatch.setattr(litellm, "input_callback", [])
monkeypatch.setattr(litellm, "service_callback", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
try:
audit_logger = _resolve_audit_log_callback("otel")
expected_callback_type = OpenTelemetryV2 if expected_type == "OpenTelemetryV2" else OpenTelemetry
assert type(audit_logger) is expected_callback_type
assert audit_logger.callback_name == "otel"
assert _resolve_audit_log_callback("otel") is audit_logger
assert litellm_logging._in_memory_loggers == []
assert all(
audit_logger is not registered_callback
for callback_registry in (
litellm.input_callback,
litellm.service_callback,
litellm.success_callback,
litellm.failure_callback,
litellm._async_success_callback,
litellm._async_failure_callback,
litellm.callbacks,
)
for registered_callback in callback_registry
)
assert manager.get_dashboard_callback_registrations() == ()
finally:
is_otel_v2_enabled.cache_clear()
@pytest.mark.parametrize(
("premium_user", "configured_value", "environment_value", "expected"),
(
@ -156,9 +214,7 @@ class TestDispatchAuditLogToCallbacks:
async def test_nonblocking_on_callback_failure(self, monkeypatch: pytest.MonkeyPatch):
"""Callback errors should not propagate."""
mock_logger = MagicMock(spec=CustomLogger)
mock_logger.async_log_audit_log_event = AsyncMock(
side_effect=RuntimeError("boom")
)
mock_logger.async_log_audit_log_event = AsyncMock(side_effect=RuntimeError("boom"))
monkeypatch.setattr(litellm, "audit_log_callbacks", [mock_logger])
audit_log = _make_audit_log()
@ -264,9 +320,7 @@ class TestCreateAuditLogForUpdateWithCallbacks:
patch("litellm.store_audit_logs", True),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
):
mock_prisma.db.litellm_auditlog.create = AsyncMock(
side_effect=RuntimeError("DB connection lost")
)
mock_prisma.db.litellm_auditlog.create = AsyncMock(side_effect=RuntimeError("DB connection lost"))
audit_log = _make_audit_log()
await create_audit_log_for_update(audit_log)
@ -282,9 +336,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.return_value = RuntimeError("callback failed")
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_called_once()
assert "callback failed" in str(mock_logger.error.call_args)
@ -294,9 +346,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.return_value = None
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_not_called()
@ -305,9 +355,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.side_effect = asyncio.CancelledError()
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_not_called()
@ -386,9 +434,7 @@ class TestS3AuditCallbackParamsDecoupling:
from litellm.proxy.management_helpers import audit_logs as ll_audit_logs
monkeypatch.setattr(litellm, "s3_callback_params", litellm.s3_callback_params)
monkeypatch.setattr(
litellm, "s3_audit_callback_params", getattr(litellm, "s3_audit_callback_params", None)
)
monkeypatch.setattr(litellm, "s3_audit_callback_params", getattr(litellm, "s3_audit_callback_params", None))
ll_audit_logs._audit_log_callback_cache.clear()
ll_logging._in_memory_loggers.clear()
yield
@ -452,7 +498,6 @@ class TestS3AuditCallbackParamsDecoupling:
def test_empty_dict_opts_in(self, monkeypatch: pytest.MonkeyPatch):
"""`s3_audit_callback_params = {}` is opt-in (truthy-by-presence) and
produces a separate instance with no bucket configured (env/IAM-only)."""
from litellm.integrations.s3_v2 import S3Logger
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
)

View file

@ -94,10 +94,7 @@ def test_config_update_no_db_error(client, auth_as, monkeypatch):
json={"general_settings": {"alerting": ["slack"]}},
)
assert response.status_code != 200
assert (
"db" in str(response.json()).lower()
or "connect" in str(response.json()).lower()
)
assert "db" in str(response.json()).lower() or "connect" in str(response.json()).lower()
# ---------------------------------------------------------------------------
@ -138,9 +135,7 @@ def test_config_field_update_happy_admin(client, auth_as, mock_prisma, monkeypat
}
def test_config_field_update_non_admin_rejected(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_update_non_admin_rejected(client, auth_as, mock_prisma, monkeypatch):
"""Non-admin cannot update config fields — returns 400 with not-allowed
detail (handler uses 400 for the auth gate, not 403)."""
from litellm.proxy import proxy_server as ps
@ -200,9 +195,7 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/field/info", params={"field_name": "max_parallel_requests"}
)
response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"})
assert response.status_code == 200
assert normalize(response.json()) == {
"field_name": "max_parallel_requests",
@ -210,9 +203,7 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch
}
def test_config_field_info_non_admin_rejected(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_info_non_admin_rejected(client, auth_as, mock_prisma, monkeypatch):
"""Non-admin (INTERNAL_USER) is denied — admin-view gate fires."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
@ -221,9 +212,7 @@ def test_config_field_info_non_admin_rejected(
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.INTERNAL_USER):
response = client.get(
"/config/field/info", params={"field_name": "max_parallel_requests"}
)
response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"})
assert response.status_code == 400
assert "error" in response.json().get("detail", {})
@ -240,16 +229,12 @@ def test_config_field_info_field_not_in_db(client, auth_as, mock_prisma, monkeyp
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/field/info", params={"field_name": "max_parallel_requests"}
)
response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"})
assert response.status_code == 400
assert "not in DB" in response.json().get("detail", {}).get("error", "")
def test_config_field_info_redacts_nested_secret_for_view_only_admin(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_info_redacts_nested_secret_for_view_only_admin(client, auth_as, mock_prisma, monkeypatch):
"""A view-only admin reading a structured field must not receive nested
credentials. database_args carries aws_web_identity_token (a DynamoDB
role-assumption credential); it must come back redacted while non-secret
@ -270,9 +255,7 @@ def test_config_field_info_redacts_nested_secret_for_view_only_admin(
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
response = client.get(
"/config/field/info", params={"field_name": "database_args"}
)
response = client.get("/config/field/info", params={"field_name": "database_args"})
assert response.status_code == 200
value = response.json()["field_value"]
assert value["aws_web_identity_token"] == "REDACTED"
@ -280,9 +263,7 @@ def test_config_field_info_redacts_nested_secret_for_view_only_admin(
assert value["user_table_name"] == "LiteLLM_UserTable"
def test_config_field_info_full_admin_sees_nested_secret(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_info_full_admin_sees_nested_secret(client, auth_as, mock_prisma, monkeypatch):
"""The redaction must not over-redact for a full PROXY_ADMIN, who needs
the real nested value to populate the edit form."""
from litellm.proxy import proxy_server as ps
@ -300,18 +281,14 @@ def test_config_field_info_full_admin_sees_nested_secret(
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/field/info", params={"field_name": "database_args"}
)
response = client.get("/config/field/info", params={"field_name": "database_args"})
assert response.status_code == 200
value = response.json()["field_value"]
assert value["aws_web_identity_token"] == "sk-super-secret-token"
assert value["region_name"] == "us-east-1"
def test_config_field_info_redacts_top_level_scalar_for_view_only(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_info_redacts_top_level_scalar_for_view_only(client, auth_as, mock_prisma, monkeypatch):
"""The top-level scalar branch must also redact for a view-only admin.
database_url carries DB credentials and is not caught by the name masker,
so it is in the explicit secret set."""
@ -325,9 +302,7 @@ def test_config_field_info_redacts_top_level_scalar_for_view_only(
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
response = client.get(
"/config/field/info", params={"field_name": "database_url"}
)
response = client.get("/config/field/info", params={"field_name": "database_url"})
assert response.status_code == 200
assert response.json()["field_value"] == "REDACTED"
@ -341,17 +316,12 @@ def test_redact_general_setting_value_recurses_list_of_dicts():
{"path": "/foo", "headers": {"Authorization": "Bearer sk-x"}},
{"path": "/bar", "client_secret": "sk-y"},
]
redacted = ps._redact_general_setting_value(
"some_list_field", value, is_full_admin=False
)
redacted = ps._redact_general_setting_value("some_list_field", value, is_full_admin=False)
assert redacted[0]["headers"]["Authorization"] == "REDACTED"
assert redacted[0]["path"] == "/foo"
assert redacted[1]["client_secret"] == "REDACTED"
assert redacted[1]["path"] == "/bar"
assert (
ps._redact_general_setting_value("some_list_field", value, is_full_admin=True)
== value
)
assert ps._redact_general_setting_value("some_list_field", value, is_full_admin=True) == value
def test_redact_secret_values_in_obj_fails_closed_at_max_depth():
@ -369,22 +339,16 @@ def test_redact_secret_values_in_obj_fails_closed_at_max_depth():
for _ in range(ps._REDACT_SECRET_MAX_DEPTH + 2):
nested = {"wrap": nested}
out = ps._redact_general_setting_value(
"some_struct_field", nested, is_full_admin=False
)
out = ps._redact_general_setting_value("some_struct_field", nested, is_full_admin=False)
# the secret must not survive anywhere in the returned tree
assert "sk-leak-bottom" not in repr(out)
# full admin is unaffected by the cap — the value comes back untouched
admin_out = ps._redact_general_setting_value(
"some_struct_field", nested, is_full_admin=True
)
admin_out = ps._redact_general_setting_value("some_struct_field", nested, is_full_admin=True)
assert admin_out is nested
def test_config_list_redacts_pass_through_secret_for_view_only(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_list_redacts_pass_through_secret_for_view_only(client, auth_as, mock_prisma, monkeypatch):
"""/config/list must not leak pass_through_endpoints upstream credentials
to a view-only admin. pass_through_endpoints is a known secret-bearing
field, so a non-admin gets it redacted; a full admin still sees it."""
@ -411,24 +375,16 @@ def test_config_list_redacts_pass_through_secret_for_view_only(
)
def _pass_through_value(body):
return next(
entry["field_value"]
for entry in body
if entry["field_name"] == "pass_through_endpoints"
)
return next(entry["field_value"] for entry in body if entry["field_name"] == "pass_through_endpoints")
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
view_resp = client.get(
"/config/list", params={"config_type": "general_settings"}
)
view_resp = client.get("/config/list", params={"config_type": "general_settings"})
assert view_resp.status_code == 200
assert "sk-UPSTREAM-SECRET" not in view_resp.text
assert _pass_through_value(view_resp.json()) == "REDACTED"
with auth_as(LitellmUserRoles.PROXY_ADMIN):
admin_resp = client.get(
"/config/list", params={"config_type": "general_settings"}
)
admin_resp = client.get("/config/list", params={"config_type": "general_settings"})
assert admin_resp.status_code == 200
admin_value = _pass_through_value(admin_resp.json())
assert admin_value[0]["headers"]["Authorization"] == "Bearer sk-UPSTREAM-SECRET"
@ -452,9 +408,7 @@ def test_config_list_happy_admin(client, auth_as, mock_prisma, monkeypatch):
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/list", params={"config_type": "general_settings"}
)
response = client.get("/config/list", params={"config_type": "general_settings"})
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
@ -560,9 +514,7 @@ def test_config_list_non_admin_rejected(client, auth_as, mock_prisma, monkeypatc
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.INTERNAL_USER):
response = client.get(
"/config/list", params={"config_type": "general_settings"}
)
response = client.get("/config/list", params={"config_type": "general_settings"})
assert response.status_code == 400
assert "role" in response.json().get("detail", {}).get("error", "").lower()
@ -575,9 +527,7 @@ def test_config_list_no_db_error(client, auth_as, monkeypatch):
monkeypatch.setattr(ps, "prisma_client", None)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/list", params={"config_type": "general_settings"}
)
response = client.get("/config/list", params={"config_type": "general_settings"})
assert response.status_code == 400
assert "error" in response.json().get("detail", {})
@ -621,9 +571,7 @@ def test_config_field_delete_happy_admin(client, auth_as, mock_prisma, monkeypat
}
def test_config_field_delete_non_admin_rejected(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_delete_non_admin_rejected(client, auth_as, mock_prisma, monkeypatch):
"""Non-admin caller hits the 400 not-allowed branch with role in detail."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
@ -643,9 +591,7 @@ def test_config_field_delete_non_admin_rejected(
assert "role" in response.json().get("detail", {}).get("error", "").lower()
def test_config_field_delete_field_not_in_config(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_field_delete_field_not_in_config(client, auth_as, mock_prisma, monkeypatch):
"""If there is no general_settings row at all, returns 400 'not in config'."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
@ -690,9 +636,7 @@ def test_config_callback_delete_happy_admin(client, auth_as, mock_prisma, monkey
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.post(
"/config/callback/delete", json={"callback_name": "langfuse"}
)
response = client.post("/config/callback/delete", json={"callback_name": "langfuse"})
assert response.status_code == 200
# `deleted_at` is an ISO timestamp generated at request time — extend
# the volatile set just for this assertion so dict-equality still works.
@ -705,9 +649,7 @@ def test_config_callback_delete_happy_admin(client, auth_as, mock_prisma, monkey
}
def test_config_callback_delete_non_admin_rejected(
client, auth_as, mock_prisma, monkeypatch
):
def test_config_callback_delete_non_admin_rejected(client, auth_as, mock_prisma, monkeypatch):
"""Non-admin caller is rejected with 400 not-allowed."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
@ -717,9 +659,7 @@ def test_config_callback_delete_non_admin_rejected(
monkeypatch.setattr(ps, "store_model_in_db", True)
with auth_as(LitellmUserRoles.INTERNAL_USER):
response = client.post(
"/config/callback/delete", json={"callback_name": "langfuse"}
)
response = client.post("/config/callback/delete", json={"callback_name": "langfuse"})
assert response.status_code == 400
assert "role" in response.json().get("detail", {}).get("error", "").lower()
@ -734,22 +674,15 @@ def test_config_callback_delete_not_found(client, auth_as, mock_prisma, monkeypa
monkeypatch.setattr(ps, "store_model_in_db", True)
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={"litellm_settings": {"success_callback": ["slack"]}}
)
fake_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {"success_callback": ["slack"]}})
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.post(
"/config/callback/delete", json={"callback_name": "langfuse"}
)
response = client.post("/config/callback/delete", json={"callback_name": "langfuse"})
# The handler re-raises HTTPException(404) verbatim (only generic
# `Exception` becomes a 500 ProxyException), so pin 404 strictly.
assert response.status_code == 404
assert (
"langfuse" in str(response.json()).lower()
or "not found" in str(response.json()).lower()
)
assert "langfuse" in str(response.json()).lower() or "not found" in str(response.json()).lower()
# ---------------------------------------------------------------------------
@ -813,10 +746,367 @@ def test_get_config_callbacks_internal_error(client, auth_as, mock_prisma, monke
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code >= 400
assert (
"boom" in str(response.json()).lower()
or "error" in str(response.json()).lower()
assert "boom" in str(response.json()).lower() or "error" in str(response.json()).lower()
def test_get_config_callbacks_redacts_runtime_only_callback_for_view_only_admin(
client, auth_as, mock_prisma, monkeypatch
):
import litellm
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", ["langsmith"])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {},
"general_settings": {},
"environment_variables": {"LANGSMITH_API_KEY": "secret"},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert "secret" not in response.text
assert (
next(callback for callback in response.json()["callbacks"] if callback["name"] == "langsmith")["variables"][
"LANGSMITH_API_KEY"
]
== "REDACTED"
)
def test_get_config_callbacks_includes_runtime_callback_with_different_type(client, auth_as, mock_prisma, monkeypatch):
import litellm
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", ["langsmith"])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", ["langsmith"])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["langsmith"]},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
callbacks = response.json()["callbacks"]
assert [
(callback["type"], callback.get("read_only", False))
for callback in callbacks
if callback["name"] == "langsmith"
] == [("success", False), ("success_and_failure", True)]
def test_get_config_callbacks_canonicalizes_runtime_callback_aliases(client, auth_as, mock_prisma, monkeypatch):
import litellm
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(
litellm,
"success_callback",
["s3_v2", "opentelemetry", "aws_sqs", "langfuse_otel"],
)
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["s3", "otel"]},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
callbacks = response.json()["callbacks"]
assert [(callback["name"], callback["type"], callback.get("read_only", False)) for callback in callbacks] == [
("s3", "success", False),
("otel", "success", False),
("sqs", "success", True),
("langfuse_otel", "success", True),
]
def test_get_config_callbacks_does_not_duplicate_configured_combined_runtime_callback(
client, auth_as, mock_prisma, monkeypatch
):
import litellm
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", ["langsmith"])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", ["langsmith"])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"callbacks": ["langsmith"]},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert [
(callback["name"], callback["type"], callback.get("read_only", False))
for callback in response.json()["callbacks"]
] == [("langsmith", "success_and_failure", False)]
def test_get_config_callbacks_does_not_duplicate_named_generic_api_callback(client, auth_as, mock_prisma, monkeypatch):
import litellm
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", [object.__new__(GenericAPILogger)])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["my_webhook"]},
"callback_settings": {
"my_webhook": {
"callback_type": "generic_api",
"endpoint": "https://example.com/events",
"headers": {"Content-Type": "application/json"},
}
},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert [
(callback["name"], callback["type"], callback.get("read_only", False))
for callback in response.json()["callbacks"]
] == [("my_webhook", "success", False)]
def test_get_config_callbacks_does_not_duplicate_compatible_generic_api_callback(
client, auth_as, mock_prisma, monkeypatch
):
import litellm
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", [object.__new__(GenericAPILogger)])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["sumologic"]},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert [
(callback["name"], callback["type"], callback.get("read_only", False))
for callback in response.json()["callbacks"]
] == [("sumologic", "success", False)]
def test_get_config_callbacks_does_not_duplicate_compatible_generic_api_callback_with_malformed_settings(
client, auth_as, mock_prisma, monkeypatch
):
import litellm
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "callback_settings", {"unrelated": {"callback_type": "generic_api"}})
monkeypatch.setattr(litellm, "success_callback", [object.__new__(GenericAPILogger)])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["sumologic"]},
"callback_settings": "malformed",
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert [
(callback["name"], callback["type"], callback.get("read_only", False))
for callback in response.json()["callbacks"]
] == [("sumologic", "success", False)]
def test_get_config_callbacks_includes_runtime_only_callback(client, auth_as, mock_prisma, monkeypatch):
import litellm
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", ["langfuse", "langsmith", "internal_callback"])
monkeypatch.setattr(litellm, "_async_success_callback", ["langsmith"])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["langfuse"]},
"general_settings": {},
"environment_variables": {},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
callbacks = response.json()["callbacks"]
callback_names = [callback["name"] for callback in callbacks]
assert callback_names.count("langfuse") == 1
assert callback_names.count("langsmith") == 1
assert "internal_callback" not in callback_names
assert next(callback for callback in callbacks if callback["name"] == "langsmith") == {
"name": "langsmith",
"variables": {
"LANGSMITH_API_KEY": None,
"LANGSMITH_PROJECT": None,
"LANGSMITH_DEFAULT_RUN_NAME": None,
},
"type": "success",
"read_only": True,
}
def test_get_config_callbacks_includes_and_redacts_runtime_only_azure_sentinel(
client, auth_as, mock_prisma, monkeypatch
):
import litellm
from litellm.integrations.azure_sentinel.azure_sentinel import AzureSentinelLogger
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "llm_router", None)
monkeypatch.setattr(litellm, "success_callback", ["langfuse", object.__new__(AzureSentinelLogger)])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "failure_callback", [])
monkeypatch.setattr(litellm, "_async_failure_callback", [])
monkeypatch.setattr(litellm, "callbacks", [])
fake_proxy_config = MagicMock()
fake_proxy_config.get_config = AsyncMock(
return_value={
"litellm_settings": {"success_callback": ["langfuse"]},
"general_settings": {},
"environment_variables": {
"AZURE_SENTINEL_CLIENT_SECRET": "azure-sentinel-secret",
"AZURE_SENTINEL_ENDPOINT": "https://example.com",
},
}
)
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
response = client.get("/get/config/callbacks")
assert response.status_code == 200
assert "azure-sentinel-secret" not in response.text
callbacks = response.json()["callbacks"]
assert [(callback["name"], callback["type"], callback.get("read_only", False)) for callback in callbacks] == [
("langfuse", "success", False),
("azure_sentinel", "success", True),
]
azure_sentinel = callbacks[-1]
assert azure_sentinel["variables"]["AZURE_SENTINEL_CLIENT_SECRET"] == "REDACTED"
assert azure_sentinel["variables"]["AZURE_SENTINEL_ENDPOINT"] == "https://example.com"
_CALLBACK_ENV_FIXTURE = {
@ -850,14 +1140,10 @@ def _install_callbacks_config(monkeypatch, mock_prisma):
def _callback_variables(body: dict, name: str) -> dict:
return next(
cb["variables"] for cb in body["callbacks"] if cb["name"] == name
)
return next(cb["variables"] for cb in body["callbacks"] if cb["name"] == name)
def test_get_config_callbacks_redacts_secret_env_vars_for_view_only_admin(
client, auth_as, mock_prisma, monkeypatch
):
def test_get_config_callbacks_redacts_secret_env_vars_for_view_only_admin(client, auth_as, mock_prisma, monkeypatch):
from litellm.proxy._types import LitellmUserRoles
_install_callbacks_config(monkeypatch, mock_prisma)
@ -889,9 +1175,7 @@ def test_get_config_callbacks_redacts_secret_env_vars_for_view_only_admin(
assert otel_vars["OTEL_ENDPOINT"] == _CALLBACK_ENV_FIXTURE["OTEL_ENDPOINT"]
def test_get_config_callbacks_full_admin_still_sees_secret_env_vars(
client, auth_as, mock_prisma, monkeypatch
):
def test_get_config_callbacks_full_admin_still_sees_secret_env_vars(client, auth_as, mock_prisma, monkeypatch):
from litellm.proxy._types import LitellmUserRoles
_install_callbacks_config(monkeypatch, mock_prisma)
@ -912,9 +1196,7 @@ def test_get_config_callbacks_full_admin_still_sees_secret_env_vars(
assert otel_vars["OTEL_HEADERS"] == _CALLBACK_ENV_FIXTURE["OTEL_HEADERS"]
def test_get_config_callbacks_redacts_slack_webhook_urls_for_view_only_admin(
client, auth_as, mock_prisma, monkeypatch
):
def test_get_config_callbacks_redacts_slack_webhook_urls_for_view_only_admin(client, auth_as, mock_prisma, monkeypatch):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
@ -1048,9 +1330,7 @@ def test_config_yaml_returns_demo_payload(client, auth_as):
response = client.request("GET", "/config/yaml", json={})
shape = {
"status": response.status_code,
"media_type_yaml": response.headers.get("content-type", "").startswith(
"application/json"
),
"media_type_yaml": response.headers.get("content-type", "").startswith("application/json"),
"has_body": len(response.content) > 0,
}
assert shape == {

View file

@ -97,6 +97,18 @@ describe("LoggingCallbacksTable", () => {
expect(onDelete).toHaveBeenCalledWith(callback);
});
it("should not render actions for a read-only callback", () => {
const callback = {
name: "langsmith",
type: "success" as const,
read_only: true,
variables: baseVars,
};
render(<LoggingCallbacksTable callbacks={[callback]} availableCallbacks={{}} />);
expect(screen.queryByTestId("callback-actions-langsmith-success")).not.toBeInTheDocument();
});
// Regression: `/get_callbacks` returns the same `name` twice when a
// callback is registered for both success and failure (e.g. `generic_api`
// → POST to spend-log on both 200 and 4xx/5xx). The UI used to ignore

View file

@ -50,6 +50,10 @@ interface CallbackRowActionsProps {
}
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
if (callback.read_only) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger

View file

@ -7,6 +7,7 @@ export interface AlertingObject {
// UI must read this to render the correct badge; missing it caused
// every row to render as "Success".
type?: "success" | "failure" | "success_and_failure";
read_only?: boolean;
variables: AlertingVariables;
}