diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py
index 9b612993a69..31523c9309d 100644
--- a/litellm/litellm_core_utils/logging_callback_manager.py
+++ b/litellm/litellm_core_utils/logging_callback_manager.py
@@ -441,7 +441,15 @@ class LoggingCallbackManager:
return result
+ def get_callback_objects(self) -> tuple[tuple[str, CustomLogger | Callable], ...]:
+ return tuple(
+ (self._get_callback_string(callback), callback)
+ for callback in self._get_all_callbacks()
+ if not isinstance(callback, str)
+ )
+
def _get_callback_string(self, callback: CustomLogger | Callable | str) -> str:
+ from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.litellm_core_utils.custom_logger_registry import (
CustomLoggerRegistry,
)
@@ -449,6 +457,8 @@ class LoggingCallbackManager:
"""Convert a callback to its string representation"""
if isinstance(callback, str):
return callback
+ elif isinstance(callback, OpenTelemetry) and callback.callback_name is not None:
+ return callback.callback_name
elif isinstance(callback, CustomLogger):
# Try to get the string representation from the registry
callback_str: Final = CustomLoggerRegistry.get_callback_str_from_class_type(type(callback))
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 30ff47ac9f9..3bbe932f5d0 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -268,7 +268,7 @@ from litellm.constants import (
WEEKLY_SPEND_REPORT_JOB_ID,
)
from litellm.exceptions import RejectedRequestError
-from litellm.integrations.custom_guardrail import ModifyResponseException
+from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.litellm_core_utils.agentic_loop_settings import (
@@ -17655,6 +17655,66 @@ async def delete_callback(
)
+def _normalize_callback_alias(callback_name: str) -> str:
+ callback_aliases: Final = (
+ ("opentelemetry", "otel"),
+ ("s3_v2", "s3"),
+ ("aws_sqs", "sqs"),
+ ("custom_callback_api", "generic_api"),
+ )
+ return next(
+ (canonical_name for alias, canonical_name in callback_aliases if alias == callback_name),
+ callback_name,
+ )
+
+
+def _callback_module_name(callback: CustomLogger | Callable[..., object]) -> str:
+ if inspect.ismethod(callback):
+ return callback.__func__.__module__
+ if inspect.isfunction(callback):
+ return callback.__module__
+ return type(callback).__module__
+
+
+def _is_litellm_internal_callback(callback_name: str, callback: CustomLogger | Callable[..., object]) -> bool:
+ from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry
+
+ module_owner: Final = _callback_module_name(callback).partition(".")[0]
+ is_registered_integration: Final = callback_name in CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE
+ return not is_registered_integration and module_owner in ("litellm", "litellm_enterprise")
+
+
+def _is_instance_of_configured_callback(
+ callback_name: str, callback: CustomLogger | Callable[..., object], configured_classes: tuple[type, ...]
+) -> bool:
+ """Self-naming OTel-family instances (`arize`, `weave_otel`) match by name, so a configured `logfire` (a bare
+ `OpenTelemetry`) does not hide YAML-configured siblings of the same class."""
+ from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry
+
+ class_derived_name: Final = CustomLoggerRegistry.get_callback_str_from_class_type(type(callback))
+ return isinstance(callback, configured_classes) and callback_name in (class_derived_name, type(callback).__name__)
+
+
+def _hidden_runtime_callback_names(configured_callback_names: frozenset[str]) -> frozenset[str]:
+ from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry
+
+ configured_classes: Final = tuple(
+ CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE[name]
+ for name in configured_callback_names
+ if name in CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE
+ )
+ configured_modules: Final = frozenset(name.rsplit(".", 1)[0] for name in configured_callback_names if "." in name)
+ internal_callback_names: Final = frozenset({"cache", "vector_store_pre_call_hook"})
+ return internal_callback_names | frozenset(
+ callback_name
+ for callback_name, callback in litellm.logging_callback_manager.get_callback_objects()
+ if isinstance(callback, CustomGuardrail)
+ or _is_litellm_internal_callback(callback_name, callback)
+ or _is_instance_of_configured_callback(callback_name, callback, configured_classes)
+ or _callback_module_name(callback) in configured_modules
+ )
+
+
@router.get(
"/get/config/callbacks",
tags=["config.yaml"],
@@ -17687,10 +17747,10 @@ async def get_config(
# Normalize string callbacks to lists
def normalize_callback(callback):
if isinstance(callback, str):
- return [callback]
- elif callback is None:
- return []
- return callback
+ return (callback,)
+ if callback is None:
+ return ()
+ return tuple(callback) if isinstance(callback, (list, dict)) else ()
_success_callbacks = normalize_callback(_success_callbacks)
_failure_callbacks = normalize_callback(_failure_callbacks)
@@ -17721,6 +17781,30 @@ async def get_config(
for _callback in _success_and_failure_callbacks:
_data_to_return.append(process_callback(_callback, "success_and_failure", environment_variables))
+ configured_callback_names: Final = frozenset(
+ _normalize_callback_alias(callback)
+ for callback in (_success_callbacks + _failure_callbacks + _success_and_failure_callbacks)
+ )
+ runtime_callbacks_by_type: Final = litellm.logging_callback_manager.get_callbacks_by_type()
+ hidden_callback_names: Final = _hidden_runtime_callback_names(configured_callback_names)
+ runtime_callback_rows: Final = tuple(
+ (_normalize_callback_alias(callback_name), callback_type)
+ for callback_type, callback_names in (
+ ("success", runtime_callbacks_by_type["success"]),
+ ("failure", runtime_callbacks_by_type["failure"]),
+ ("success_and_failure", runtime_callbacks_by_type["success_and_failure"]),
+ )
+ for callback_name in callback_names
+ if callback_name not in hidden_callback_names
+ )
+ runtime_only_rows: Final = sorted(
+ frozenset(row for row in runtime_callback_rows if row[0] not in configured_callback_names)
+ )
+ _data_to_return.extend(
+ dict(process_callback(callback_name, callback_type, environment_variables), read_only=True)
+ for callback_name, callback_type in runtime_only_rows
+ )
+
_data_to_return = _apply_callback_role_gate(_data_to_return, is_full_admin)
# Check if slack alerting is on
diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py
index 54cce9cdd78..d06eb0426c9 100644
--- a/tests/proxy_unit_tests/test_proxy_server.py
+++ b/tests/proxy_unit_tests/test_proxy_server.py
@@ -2920,7 +2920,7 @@ async def test_get_config_callbacks_with_all_types(client_no_auth):
assert result["status"] == "success"
assert "callbacks" in result
- callbacks = result["callbacks"]
+ callbacks = [cb for cb in result["callbacks"] if not cb.get("read_only", False)]
# Verify we have all 5 callbacks (2 success + 1 failure + 2 success_and_failure)
assert len(callbacks) == 5
diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_config.py b/tests/test_litellm/proxy/proxy_server/test_routes_config.py
index dcb63b8ca82..2d9c1bd8b46 100644
--- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py
+++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py
@@ -13,9 +13,12 @@ Routes covered:
from __future__ import annotations
+import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
+import pytest
+
from .conftest import VOLATILE_KEYS, normalize
@@ -229,10 +232,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()
# ---------------------------------------------------------------------------
@@ -273,9 +273,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
@@ -335,9 +333,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",
@@ -345,9 +341,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
@@ -356,9 +350,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", {})
@@ -375,16 +367,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
@@ -405,9 +393,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"
@@ -415,9 +401,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
@@ -435,18 +419,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."""
@@ -460,9 +440,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"
@@ -476,17 +454,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():
@@ -504,22 +477,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."""
@@ -546,24 +513,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"
@@ -587,9 +546,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)
@@ -695,9 +652,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()
@@ -710,9 +665,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", {})
@@ -756,9 +709,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
@@ -778,9 +729,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
@@ -825,9 +774,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.
@@ -840,9 +787,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
@@ -852,9 +797,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()
@@ -869,22 +812,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()
# ---------------------------------------------------------------------------
@@ -948,10 +884,7 @@ 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()
_CALLBACK_ENV_FIXTURE = {
@@ -985,14 +918,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)
@@ -1024,9 +953,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)
@@ -1047,9 +974,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
@@ -1170,6 +1095,413 @@ def test_get_config_callbacks_redacts_email_alerting_vars_for_view_only_admin(
assert admin_email["SMTP_HOST"] == "smtp.resend.com"
+def test_get_config_callbacks_appends_runtime_only_callbacks(client, auth_as, mock_prisma, monkeypatch):
+ """LIT-5281: a YAML callback that the DB callback list replaced in the merged config still runs, so it must
+ show up as a read_only row next to the editable DB-configured one."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": ["langfuse"]},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+ from litellm.integrations.langsmith import LangsmithLogger
+ from litellm.integrations.opentelemetry import OpenTelemetry
+
+ monkeypatch.setattr(litellm, "success_callback", ["langfuse", LangsmithLogger()])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+ monkeypatch.setattr(litellm, "callbacks", [OpenTelemetry()])
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ response = client.get("/get/config/callbacks")
+ assert response.status_code == 200
+
+ assert [(cb["name"], cb["type"], cb.get("read_only", False)) for cb in response.json()["callbacks"]] == [
+ ("langfuse", "success", False),
+ ("langsmith", "success", True),
+ ("otel", "success_and_failure", True),
+ ]
+
+
+def test_get_config_callbacks_accepts_scalar_and_null_yaml_callbacks(client, auth_as, mock_prisma, monkeypatch):
+ """`success_callback: langfuse` (a YAML scalar) is one configured callback, not eight single-letter ones, and a
+ `callbacks: null` key contributes nothing."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": "langfuse", "failure_callback": None, "callbacks": None},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+ from litellm.integrations.langsmith import LangsmithLogger
+
+ monkeypatch.setattr(litellm, "success_callback", ["langfuse", LangsmithLogger()])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+ monkeypatch.setattr(litellm, "callbacks", [])
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ response = client.get("/get/config/callbacks")
+ assert response.status_code == 200
+
+ assert [(cb["name"], cb["type"], cb.get("read_only", False)) for cb in response.json()["callbacks"]] == [
+ ("langfuse", "success", False),
+ ("langsmith", "success", True),
+ ]
+
+
+def test_get_config_callbacks_deduplicates_configured_and_runtime(client, auth_as, mock_prisma, monkeypatch):
+ """A configured callback shows once as editable, whether the runtime holds its string or an initialized instance
+ (arize initializes an ArizeLogger, logfire a bare OpenTelemetry that only its class identifies)."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": ["langfuse", "arize", "logfire"]},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+ from litellm.integrations.arize.arize import ArizeLogger
+ from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
+
+ arize_logger = ArizeLogger(config=OpenTelemetryConfig(exporter="console"), callback_name="arize")
+ monkeypatch.setattr(litellm, "success_callback", ["langfuse", arize_logger, OpenTelemetry()])
+ monkeypatch.setattr(litellm, "callbacks", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ response = client.get("/get/config/callbacks")
+ assert response.status_code == 200
+
+ assert [(cb["name"], cb["type"], cb.get("read_only", False)) for cb in response.json()["callbacks"]] == [
+ ("langfuse", "success", False),
+ ("arize", "success", False),
+ ("logfire", "success", False),
+ ]
+
+
+def test_get_config_callbacks_keeps_yaml_otel_family_callbacks_next_to_configured_one(
+ client, auth_as, mock_prisma, monkeypatch
+):
+ """LIT-5281: arize, weave_otel and langfuse_otel all initialize OpenTelemetry subclasses. Saving one of them
+ from the dashboard replaces the YAML `callbacks` list, so the YAML siblings keep running and must stay listed
+ under their own names instead of being hidden as duplicates of the configured OTel callback."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"callbacks": ["langfuse_otel"]},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+ from litellm.integrations.arize.arize import ArizeLogger
+ from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
+ from litellm.integrations.langsmith import LangsmithLogger
+ from litellm.integrations.opentelemetry import OpenTelemetryConfig
+ from litellm.integrations.weave.weave_otel import WeaveOtelLogger
+
+ console_config = OpenTelemetryConfig(exporter="console")
+ monkeypatch.setattr(litellm, "success_callback", [LangsmithLogger()])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+ monkeypatch.setattr(
+ litellm,
+ "callbacks",
+ [
+ ArizeLogger(config=console_config, callback_name="arize"),
+ WeaveOtelLogger(config=console_config),
+ LangfuseOtelLogger(config=console_config, callback_name="langfuse_otel"),
+ ],
+ )
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ response = client.get("/get/config/callbacks")
+ assert response.status_code == 200
+
+ assert [(cb["name"], cb["type"], cb.get("read_only", False)) for cb in response.json()["callbacks"]] == [
+ ("langfuse_otel", "success_and_failure", False),
+ ("arize", "success_and_failure", True),
+ ("langsmith", "success", True),
+ ("weave_otel", "success_and_failure", True),
+ ]
+
+
+def _dotted_path_test_function(*args, **kwargs):
+ pass
+
+
+@pytest.mark.parametrize("handler_kind", ["instance", "function"])
+@pytest.mark.parametrize(
+ "config_key,expected_type",
+ [
+ ("success_callback", "success"),
+ ("failure_callback", "failure"),
+ ("callbacks", "success_and_failure"),
+ ],
+)
+def test_get_config_callbacks_deduplicates_dotted_path_callback(
+ client, auth_as, mock_prisma, monkeypatch, config_key, expected_type, handler_kind
+):
+ """A dotted-path callback stays a single editable row instead of duplicating under its class or function name."""
+ 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)
+
+ import litellm
+ from litellm.integrations.custom_logger import CustomLogger
+
+ class _DottedPathTestHandler(CustomLogger):
+ pass
+
+ dotted_handler = _DottedPathTestHandler() if handler_kind == "instance" else _dotted_path_test_function
+ dotted_path = f"{__name__}.dotted_handler"
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {config_key: [dotted_path]},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ monkeypatch.setattr(litellm, "callbacks", [dotted_handler])
+ monkeypatch.setattr(litellm, "success_callback", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+
+ 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] == [
+ (dotted_path, expected_type, False)
+ ]
+
+
+def test_get_config_callbacks_lists_dict_shaped_config_callbacks(client, auth_as, mock_prisma, monkeypatch):
+ """Dict-shaped success_callback config values list their keys as editable rows."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": {"langsmith": {"batch_size": 1}}},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+
+ monkeypatch.setattr(
+ litellm.logging_callback_manager,
+ "get_callbacks_by_type",
+ MagicMock(return_value={"success": ["langsmith"], "failure": [], "success_and_failure": []}),
+ )
+
+ 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.get("read_only", False)) for callback in callbacks] == [("langsmith", False)]
+
+
+def test_get_config_callbacks_excludes_internal_runtime_callbacks(client, auth_as, mock_prisma, monkeypatch):
+ """Proxy infrastructure callbacks are excluded from callback inventory."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": []},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
+
+ import litellm
+ from litellm._service_logger import ServiceLogging
+ from litellm.integrations.custom_guardrail import CustomGuardrail
+ from litellm.integrations.custom_logger import CustomLogger
+ from litellm.integrations.langsmith import LangsmithLogger
+ from litellm.integrations.s3_v2 import S3Logger
+ from litellm.integrations.sqs import SQSLogger
+ from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import VectorStorePreCallHook
+ from litellm.proxy.hooks.max_budget_limiter import _PROXY_MaxBudgetLimiter
+ from litellm.router import Router
+
+ class _InventoryTestGuardrail(CustomGuardrail):
+ pass
+
+ class _UserCodeLogger(CustomLogger):
+ pass
+
+ def user_code_function(*args, **kwargs):
+ pass
+
+ async def build_aws_loggers() -> tuple[S3Logger, SQSLogger]:
+ return S3Logger(s3_bucket_name="inventory-bucket"), SQSLogger(sqs_queue_url="https://sqs.example/inventory")
+
+ s3_logger, sqs_logger = asyncio.run(build_aws_loggers())
+ router = Router(model_list=[])
+ monkeypatch.setattr(litellm, "input_callback", [])
+ monkeypatch.setattr(
+ litellm, "success_callback", [LangsmithLogger(), s3_logger, router.sync_deployment_callback_on_success]
+ )
+ monkeypatch.setattr(litellm, "_async_success_callback", [sqs_logger, router.deployment_callback_on_success])
+ monkeypatch.setattr(litellm, "failure_callback", [user_code_function])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [router.async_deployment_callback_on_failure])
+ monkeypatch.setattr(
+ litellm,
+ "callbacks",
+ [
+ _PROXY_MaxBudgetLimiter(),
+ _PROXY_LiteLLMManagedFiles(internal_usage_cache=MagicMock(), prisma_client=MagicMock()),
+ ServiceLogging(),
+ VectorStorePreCallHook(),
+ _InventoryTestGuardrail(guardrail_name="inventory-test-guardrail"),
+ _UserCodeLogger(),
+ ],
+ )
+ monkeypatch.setattr(litellm, "cache", litellm.Cache(type="local"))
+ assert "cache" in litellm.success_callback and "cache" in litellm._async_success_callback
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ response = client.get("/get/config/callbacks")
+
+ assert response.status_code == 200
+ assert [
+ (callback["name"], callback["type"], callback["read_only"]) for callback in response.json()["callbacks"]
+ ] == [
+ ("_UserCodeLogger", "success_and_failure", True),
+ ("langsmith", "success", True),
+ ("s3", "success", True),
+ ("sqs", "success", True),
+ ("user_code_function", "failure", True),
+ ]
+
+
+def test_get_config_callbacks_redacts_runtime_only_row_secrets_for_view_only_admin(
+ client, auth_as, mock_prisma, monkeypatch
+):
+ """Runtime-only callback rows are subject to the same redaction gate as configured."""
+ 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)
+
+ fake_proxy_config = MagicMock()
+ fake_proxy_config.get_config = AsyncMock(
+ return_value={
+ "litellm_settings": {"success_callback": []},
+ "general_settings": {},
+ "environment_variables": dict(_CALLBACK_ENV_FIXTURE),
+ }
+ )
+ monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
+
+ import litellm
+
+ monkeypatch.setattr(litellm, "success_callback", [])
+ monkeypatch.setattr(litellm, "_async_success_callback", [])
+ monkeypatch.setattr(litellm, "failure_callback", [])
+ monkeypatch.setattr(litellm, "_async_failure_callback", [])
+ monkeypatch.setattr(litellm, "callbacks", ["otel"])
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
+ response = client.get("/get/config/callbacks")
+ assert response.status_code == 200
+ body = response.json()
+
+ callbacks = body["callbacks"]
+ otel_cb = next((cb for cb in callbacks if cb["name"] == "otel"), None)
+ assert otel_cb is not None
+ assert otel_cb["type"] == "success_and_failure"
+ assert otel_cb["read_only"] is True
+ assert otel_cb["variables"]["OTEL_HEADERS"] == "REDACTED"
+ assert otel_cb["variables"]["OTEL_ENDPOINT"] == _CALLBACK_ENV_FIXTURE["OTEL_ENDPOINT"]
+
+ with auth_as(LitellmUserRoles.PROXY_ADMIN):
+ admin_response = client.get("/get/config/callbacks")
+ assert admin_response.status_code == 200
+ admin_body = admin_response.json()
+ admin_otel = next((cb for cb in admin_body["callbacks"] if cb["name"] == "otel"), None)
+ assert admin_otel is not None
+ assert admin_otel["variables"]["OTEL_HEADERS"] == _CALLBACK_ENV_FIXTURE["OTEL_HEADERS"]
+
+
# ---------------------------------------------------------------------------
# GET /config/yaml
# ---------------------------------------------------------------------------
@@ -1183,9 +1515,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 == {
diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx
index 3ef7dee01b0..a4d76e2cc06 100644
--- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx
@@ -97,6 +97,24 @@ describe("LoggingCallbacksTable", () => {
expect(onDelete).toHaveBeenCalledWith(callback);
});
+ it("shows a read-only label instead of the actions menu for runtime-only callback rows", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("callback-actions-langfuse-success")).toBeInTheDocument();
+ expect(screen.queryByTestId("callback-actions-datadog-success")).not.toBeInTheDocument();
+ expect(screen.getAllByText("Read only")).toHaveLength(1);
+ });
+
// 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
diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx
index 2263fe03b3d..950ef5de6e4 100644
--- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx
@@ -50,6 +50,16 @@ interface CallbackRowActionsProps {
}
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
+ if (callback.read_only) {
+ return (
+
+ Read only
+
+ );
+ }
return (