From 04bbb549f2177e095f045c5f5c7990403247d2b9 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Tue, 3 Mar 2026 15:38:43 -0800 Subject: [PATCH] patch bundle fixes --- litellm/proxy/_types.py | 14 ++- litellm/proxy/common_utils/callback_utils.py | 16 +++- litellm/proxy/proxy_server.py | 90 ++++++++++++------- .../proxy/common_utils/test_callback_utils.py | 22 +++++ .../src/components/settings.tsx | 7 +- 5 files changed, 106 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index cfb34de7231..15eb25d8bad 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2296,14 +2296,12 @@ class UserAPIKeyAuth( # If values is already an instance (not a dict), return it as-is if not isinstance(values, dict): return values - if values.get("api_key") is not None: - values.update( - {"token": cls._safe_hash_litellm_api_key(values.get("api_key"))} - ) - if isinstance(values.get("api_key"), str): - values.update( - {"api_key": cls._safe_hash_litellm_api_key(values.get("api_key"))} - ) + # api_key can be None (for team_id auth), or non-str from cache/JSON. + # Only hash when it's a str to avoid AttributeError in _safe_hash_litellm_api_key. + api_key = values.get("api_key") + if isinstance(api_key, str): + hashed = cls._safe_hash_litellm_api_key(api_key) + values.update({"token": hashed, "api_key": hashed}) return values @classmethod diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 83088a597a7..4ed9d6ab00b 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Sequence, Union import litellm from litellm import get_secret @@ -586,6 +586,20 @@ def process_callback( return result +def _dedupe_callbacks_preserve_dict_entries( + callbacks: Sequence[Any], +) -> List[Union[str, Dict[str, Any]]]: + """Deduplicate callbacks by name. Safe for unhashable dicts.""" + seen: set = set() + result: List[Union[str, Dict[str, Any]]] = [] + for c in callbacks: + name = c if isinstance(c, str) else next(iter(c.keys())) + if name not in seen: + seen.add(name) + result.append(c) + return result + + def normalize_callback_names( callbacks: Iterable[Union[str, object]] | None, ) -> List[Union[str, object]]: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b3e8c67bdd7..70415131bfd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -87,6 +87,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.common_utils.callback_utils import ( + _dedupe_callbacks_preserve_dict_entries, get_callbacks_from_callback_settings, normalize_callback_names, process_callback, @@ -2675,26 +2676,29 @@ class ProxyConfig: DeprecationWarning, stacklevel=2, ) - merged_success = list( - dict.fromkeys(success_from_settings + litellm_success) + merged_success = _dedupe_callbacks_preserve_dict_entries( + success_from_settings + litellm_success ) for callback in merged_success: + callback_name = ( + callback if isinstance(callback, str) else next(iter(callback.keys())) + ) # user passed custom_callbacks.async_on_succes_logger. They need us to import a function - if "." in callback: + if "." in callback_name: litellm.logging_callback_manager.add_litellm_success_callback( - get_instance_fn(value=callback) + get_instance_fn(value=callback_name) ) # these are litellm callbacks - "langfuse", "sentry", "wandb" else: resolved = ( litellm.logging_callback_manager._add_custom_callback_generic_api_str( - callback + callback_name ) ) litellm.logging_callback_manager.add_litellm_success_callback( resolved ) - if "prometheus" in callback: + if "prometheus" in callback_name: from litellm.integrations.prometheus import ( PrometheusLogger, ) @@ -2717,20 +2721,23 @@ class ProxyConfig: DeprecationWarning, stacklevel=2, ) - merged_failure = list( - dict.fromkeys(failure_from_settings + litellm_failure) + merged_failure = _dedupe_callbacks_preserve_dict_entries( + failure_from_settings + litellm_failure ) for callback in merged_failure: + callback_name = ( + callback if isinstance(callback, str) else next(iter(callback.keys())) + ) # user passed custom_callbacks.async_on_succes_logger. They need us to import a function - if "." in callback: + if "." in callback_name: litellm.logging_callback_manager.add_litellm_failure_callback( - get_instance_fn(value=callback) + get_instance_fn(value=callback_name) ) # these are litellm callbacks - "langfuse", "sentry", "wandb" else: resolved = ( litellm.logging_callback_manager._add_custom_callback_generic_api_str( - callback + callback_name ) ) litellm.logging_callback_manager.add_litellm_failure_callback( @@ -3614,11 +3621,11 @@ class ProxyConfig: failure_callbacks = litellm_settings.get("failure_callback", None) callbacks = litellm_settings.get("callbacks", None) - merged_success = list( - dict.fromkeys(success_from_settings + (success_callbacks or [])) + merged_success = _dedupe_callbacks_preserve_dict_entries( + success_from_settings + (success_callbacks or []) ) - merged_failure = list( - dict.fromkeys(failure_from_settings + (failure_callbacks or [])) + merged_failure = _dedupe_callbacks_preserve_dict_entries( + failure_from_settings + (failure_callbacks or []) ) seen_both = {c if isinstance(c, str) else next(iter(c)) for c in callbacks_from_settings} merged_callbacks: list = list(callbacks_from_settings) @@ -3629,15 +3636,25 @@ class ProxyConfig: seen_both.add(name) for success_callback in merged_success: + _name = ( + success_callback + if isinstance(success_callback, str) + else next(iter(success_callback.keys())) + ) self._add_callback_from_db_to_in_memory_litellm_callbacks( - callback=success_callback, + callback=_name, event_types=["success"], existing_callbacks=litellm.success_callback, ) for failure_callback in merged_failure: + _name = ( + failure_callback + if isinstance(failure_callback, str) + else next(iter(failure_callback.keys())) + ) self._add_callback_from_db_to_in_memory_litellm_callbacks( - callback=failure_callback, + callback=_name, event_types=["failure"], existing_callbacks=litellm.failure_callback, ) @@ -11613,20 +11630,16 @@ async def get_config(): # noqa: PLR0915 return [val] return list(val) if val else [] - _success_callbacks = list( - dict.fromkeys( - _success_from_settings - + normalize_callback_names( - _to_list(_litellm_settings.get("success_callback")) - ) + _success_callbacks = _dedupe_callbacks_preserve_dict_entries( + _success_from_settings + + normalize_callback_names( + _to_list(_litellm_settings.get("success_callback")) ) ) - _failure_callbacks = list( - dict.fromkeys( - _failure_from_settings - + normalize_callback_names( - _to_list(_litellm_settings.get("failure_callback")) - ) + _failure_callbacks = _dedupe_callbacks_preserve_dict_entries( + _failure_from_settings + + normalize_callback_names( + _to_list(_litellm_settings.get("failure_callback")) ) ) _litellm_callbacks = _litellm_settings.get("callbacks", []) or [] @@ -11655,29 +11668,40 @@ async def get_config(): # noqa: PLR0915 """ for _callback in _success_callbacks: + _name = ( + _callback if isinstance(_callback, str) else next(iter(_callback.keys())) + ) _data_to_return.append( - process_callback(_callback, "success", environment_variables) + process_callback(_name, "success", environment_variables) ) for _callback in _failure_callbacks: + _name = ( + _callback if isinstance(_callback, str) else next(iter(_callback.keys())) + ) _data_to_return.append( - process_callback(_callback, "failure", environment_variables) + process_callback(_name, "failure", environment_variables) ) for _callback in _success_and_failure_callbacks: _callback_params = None + _callback_name: str if isinstance(_callback, dict) and len(_callback) == 1: (_callback_name, _callback_params), = _callback.items() - _callback = _callback_name elif isinstance(_callback, str) and _callback in _callback_settings: + _callback_name = _callback _raw = _callback_settings.get(_callback) or {} _callback_params = ( {k: v for k, v in _raw.items() if k not in ("callback_type", "event_types")} or None ) + else: + _callback_name = ( + _callback if isinstance(_callback, str) else next(iter(_callback.keys())) + ) _data_to_return.append( process_callback( - _callback, + _callback_name, "success_and_failure", environment_variables, callback_params=_callback_params, diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 94025cd3615..d069c1f7e6e 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -6,6 +6,7 @@ sys.path.insert( ) # Adds the parent directory to the system path from litellm.proxy.common_utils.callback_utils import ( + _dedupe_callbacks_preserve_dict_entries, get_callbacks_from_callback_settings, get_model_group_from_request_data, get_remaining_tokens_and_requests_from_request_data, @@ -128,6 +129,27 @@ def test_normalize_callback_names_none_returns_empty_list(): assert normalize_callback_names([]) == [] +def test_dedupe_callbacks_preserve_dict_entries_handles_dict_style(): + """dict.fromkeys would crash on unhashable dicts; _dedupe_callbacks_preserve_dict_entries is safe.""" + callbacks = [ + "langfuse", + {"websearch_interception": {"enabled_providers": ["bedrock"]}}, + "langfuse", + "sentry", + ] + result = _dedupe_callbacks_preserve_dict_entries(callbacks) + assert result == [ + "langfuse", + {"websearch_interception": {"enabled_providers": ["bedrock"]}}, + "sentry", + ] + + +def test_dedupe_callbacks_preserve_dict_entries_strings_only(): + """Strings-only input dedupes correctly.""" + assert _dedupe_callbacks_preserve_dict_entries(["a", "b", "a"]) == ["a", "b"] + + def test_remaining_tokens_requests_returns_empty_when_metadata_empty_or_missing(): """Empty or missing metadata returns {}.""" assert get_remaining_tokens_and_requests_from_request_data({}) == {} diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 1edb28c215d..bf70fd760b3 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -272,6 +272,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, ...normalized, ...paramFields, callback: selectedEditCallback.name, + callback_type: selectedEditCallback.type ?? "success", }); } }, [showEditCallback, selectedEditCallback, editForm]); @@ -340,7 +341,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setIsAddingCallback(true); } - const payload = buildCallbackPayload(formValues, callbackName, callbacks, isEdit, selectedEditCallback?.type as "success" | "failure" | "success_and_failure" | undefined, callbackSettings); + const callbackType = (isEdit ? formValues.callback_type : undefined) ?? selectedEditCallback?.type as "success" | "failure" | "success_and_failure" | undefined; + const payload = buildCallbackPayload(formValues, callbackName, callbacks, isEdit, callbackType, callbackSettings); try { await setCallbacksCall(accessToken, payload); @@ -782,6 +784,9 @@ const Settings: React.FC = ({ accessToken, userRole, userID, > {selectedEditCallback && ( <> +