From d082090bac4348119bebfa089c06619c8625d49e Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 10:07:12 +0800 Subject: [PATCH 1/6] fix(router): dedupe strategy selectors by identity so every Router's selector gets registered A second Router with latency-based-routing in one process (notebook re-run, tests, multi-router SDK setups) silently lost its default selector: the LoggingCallbackManager dedupes CustomLoggers by a class-level key, and the latency handler's key has no distinguishing scalar attributes, so the second instance collided with the first one already in litellm.callbacks and was dropped. Calls made through the new Router then fed latency data into the old Router's handler/cache, leaving the new Router's `{model_group}_map` cache permanently empty and its routing blind (#44575). Add a `dedupe_by_identity` opt-in to the callback manager (identity-based membership for CustomLogger instances; duplicate adds of the same instance are still no-ops and MAX_CALLBACKS still applies) and use it in Router._register_router_selector, which is the only place per-instance strategy selectors are registered. Least-busy was unaffected by luck: its instances differ in a scalar attribute, so their keys never collided. Fixes #44575 Co-Authored-By: Claude Code --- .../logging_callback_manager.py | 69 +++++++++++++++++-- litellm/router.py | 9 ++- .../test_logging_callback_manager.py | 41 +++++++++++ .../test_router_routing_groups.py | 41 +++++++++++ 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py index 5eaa4b467fe..614810feae0 100644 --- a/litellm/litellm_core_utils/logging_callback_manager.py +++ b/litellm/litellm_core_utils/logging_callback_manager.py @@ -38,15 +38,30 @@ class LoggingCallbackManager: except Exception: return False - def add_litellm_input_callback(self, callback: CustomLogger | str | Callable[..., object]): + def add_litellm_input_callback( + self, + callback: CustomLogger | str | Callable[..., object], + dedupe_by_identity: bool = False, + ): """ Add a input callback to litellm.input_callback. Auto-routes async callbacks to litellm._async_input_callback. + + Args: + callback: the callback to add. + dedupe_by_identity: if True, CustomLogger instances are deduped by + object identity instead of the class-level key. Required for + per-instance stateful loggers (router strategy selectors) where + two live instances of the same class must coexist. """ if not isinstance(callback, str) and self._is_async_callable(callback): - self._safe_add_callback_to_list(callback=callback, parent_list=litellm._async_input_callback) + self._safe_add_callback_to_list( + callback=callback, parent_list=litellm._async_input_callback, dedupe_by_identity=dedupe_by_identity + ) else: - self._safe_add_callback_to_list(callback=callback, parent_list=litellm.input_callback) + self._safe_add_callback_to_list( + callback=callback, parent_list=litellm.input_callback, dedupe_by_identity=dedupe_by_identity + ) def add_litellm_service_callback(self, callback: CustomLogger | str | Callable[..., object]): """ @@ -54,15 +69,27 @@ class LoggingCallbackManager: """ self._safe_add_callback_to_list(callback=callback, parent_list=litellm.service_callback) - def add_litellm_callback(self, callback: CustomLogger | str | Callable[..., object]): + def add_litellm_callback( + self, + callback: CustomLogger | str | Callable[..., object], + dedupe_by_identity: bool = False, + ): """ Add a callback to litellm.callbacks Ensures no duplicates are added. + + Args: + callback: the callback to add. + dedupe_by_identity: if True, CustomLogger instances are deduped by + object identity instead of the class-level key. Required for + per-instance stateful loggers (router strategy selectors) where + two live instances of the same class must coexist. """ self._safe_add_callback_to_list( callback=callback, parent_list=litellm.callbacks, + dedupe_by_identity=dedupe_by_identity, ) def add_litellm_success_callback(self, callback: CustomLogger | str | Callable[..., object]): @@ -252,11 +279,20 @@ class LoggingCallbackManager: self, callback: CustomLogger | Callable[..., object] | str, parent_list: list[CustomLogger | Callable[..., object] | str], + dedupe_by_identity: bool = False, ): """ Safe add a callback to a list, if the callback is already in the list, do not add it again. Ensures no duplicates are added for `str`, `Callable`, and `CustomLogger` callbacks. + + Args: + callback: the callback to add. + parent_list: the list to add the callback to. + dedupe_by_identity: if True, CustomLogger instances are deduped by + object identity instead of the class-level key. Required for + per-instance stateful loggers (router strategy selectors) where + two live instances of the same class must coexist. """ # Check max callbacks limit first if not self._check_callback_list_size(parent_list): @@ -273,6 +309,7 @@ class LoggingCallbackManager: self._add_custom_logger_to_list( custom_logger=callback, parent_list=parent_list, + dedupe_by_identity=dedupe_by_identity, ) elif callable(callback): @@ -298,10 +335,34 @@ class LoggingCallbackManager: self, custom_logger: CustomLogger, parent_list: list[CustomLogger | Callable[..., object] | str], + dedupe_by_identity: bool = False, ): """ Add a custom logger to a list, if another instance of the same custom logger exists in the list, do not add it again. + + Args: + custom_logger: the logger to add. + parent_list: the list to add the logger to. + dedupe_by_identity: if True, dedupe by object identity instead of + the class-level key. Required for per-instance stateful loggers + (router strategy selectors) where two live instances of the same + class must coexist. """ + # Per-instance loggers are deduped by identity: their class-level key + # does not distinguish two live instances, and silently dropping the + # second one leaves the newest Router with an unregistered callback + # (see issue #44575). + if dedupe_by_identity: + if not any(existing is custom_logger for existing in parent_list): + parent_list.append(custom_logger) + else: + verbose_logger.debug( + "Custom logger instance %s already exists in %s, not adding again..", + custom_logger, + parent_list, + ) + return + # Check if an instance of the same class already exists in the list custom_logger_key: Final = self._get_custom_logger_key(custom_logger) custom_logger_type_name: Final = type(custom_logger).__name__ diff --git a/litellm/router.py b/litellm/router.py index f173cdb37b4..22a56fb2c1b 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1522,13 +1522,18 @@ class Router: @staticmethod def _register_router_selector(selector: RouterStrategySelector) -> None: + # Strategy selectors are per-instance stateful callbacks (each holds + # its own Router cache), so they must be deduped by identity: the + # class-level key would treat a second Router's selector as a duplicate + # of the first one and silently drop it, leaving the new Router's + # latency/usage cache permanently empty (issue #44575). if isinstance(selector, LeastBusyLoggingHandler): if isinstance(litellm.input_callback, list): - litellm.logging_callback_manager.add_litellm_input_callback(selector) + litellm.logging_callback_manager.add_litellm_input_callback(selector, dedupe_by_identity=True) else: litellm.input_callback = [selector] if isinstance(litellm.callbacks, list): - litellm.logging_callback_manager.add_litellm_callback(selector) + litellm.logging_callback_manager.add_litellm_callback(selector, dedupe_by_identity=True) def _unregister_router_selectors(self, selectors: Sequence[object]) -> None: """ diff --git a/tests/unit/litellm_core_utils/test_logging_callback_manager.py b/tests/unit/litellm_core_utils/test_logging_callback_manager.py index f8260702068..a7bb7658721 100644 --- a/tests/unit/litellm_core_utils/test_logging_callback_manager.py +++ b/tests/unit/litellm_core_utils/test_logging_callback_manager.py @@ -342,3 +342,44 @@ def test_duplicate_multiple_loggers_test(): assert langfuse_count == 1, "Should have exactly one LangfusePromptManagement instance" assert otel_count == 1, "Should have exactly one OpenTelemetry instance" + + +def test_add_custom_logger_default_mode_dedupes_same_class_instances(): + """ + Default mode keeps one instance per CustomLogger class: the class-level + key treats a second instance of the same class as a duplicate. + """ + manager = LoggingCallbackManager() + + class TestLogger(CustomLogger): + pass + + first, second = TestLogger(), TestLogger() + + manager.add_litellm_callback(first) + manager.add_litellm_callback(second) + + assert sum(1 for c in litellm.callbacks if c is first) == 1 + assert all(c is not second for c in litellm.callbacks) + + +def test_add_custom_logger_dedupe_by_identity_allows_two_instances(): + """ + `dedupe_by_identity=True` is required for per-instance stateful loggers + (router strategy selectors): two live instances of the same class must + coexist, while re-adding the same instance stays a no-op. + """ + manager = LoggingCallbackManager() + + class TestLogger(CustomLogger): + pass + + first, second = TestLogger(), TestLogger() + + manager.add_litellm_callback(first, dedupe_by_identity=True) + manager.add_litellm_callback(second, dedupe_by_identity=True) + assert sum(1 for c in litellm.callbacks if c is first) == 1 + assert sum(1 for c in litellm.callbacks if c is second) == 1 + + manager.add_litellm_callback(first, dedupe_by_identity=True) + assert sum(1 for c in litellm.callbacks if c is first) == 1 diff --git a/tests/unit/router_strategy/test_router_routing_groups.py b/tests/unit/router_strategy/test_router_routing_groups.py index e18c9948e39..dcb806fc641 100644 --- a/tests/unit/router_strategy/test_router_routing_groups.py +++ b/tests/unit/router_strategy/test_router_routing_groups.py @@ -2254,3 +2254,44 @@ async def test_update_settings_changed_routing_strategy_args_flushes_replaced_se await t except asyncio.CancelledError: pass + + +def test_two_routers_same_latency_strategy_both_register_selectors(monkeypatch): + """ + Two Routers with `latency-based-routing` in one process must both see their + default selector registered: the latency handler's class-level dedup key is + identical for every instance (no scalar instance attributes), so key-based + dedup silently dropped the second Router's selector and its latency cache + never got populated (issue #44575). + """ + monkeypatch.setattr(litellm, "callbacks", []) + monkeypatch.setattr(litellm, "input_callback", []) + + first = _build_router(routing_strategy="latency-based-routing") + second = _build_router(routing_strategy="latency-based-routing") + + first_selector = first.lowestlatency_logger + second_selector = second.lowestlatency_logger + assert first_selector is not None and second_selector is not None + assert first_selector is not second_selector + assert any(c is first_selector for c in litellm.callbacks) + assert any(c is second_selector for c in litellm.callbacks), ( + "second Router's latency selector was dropped by the callback dedup" + ) + + # Each selector feeds its own Router's cache: an event delivered through + # the global callback list must land in the emitting router's cache. + kwargs = { + "litellm_params": { + "metadata": {"model_group": "filtered-model"}, + "model_info": {"id": "deploy-1"}, + } + } + start_time = datetime.datetime.now() + for callback in litellm.callbacks: + if isinstance(callback, CustomLogger): + callback.log_success_event(kwargs, None, start_time, start_time + datetime.timedelta(seconds=0.5)) + + + assert first.cache.get_cache("filtered-model_map") is not None + assert second.cache.get_cache("filtered-model_map") is not None From 491aceaaf534cb6362139c8170bdcd1a01d00ab2 Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 13:01:17 +0800 Subject: [PATCH 2/6] fix(router): carry identity-based selector dedup into the event callback lists The previous commit registered each Router's strategy selector by identity in `litellm.callbacks`, but normal requests still dropped the second one: `function_setup` copies the general callbacks into the success/failure (and async) event lists through manager methods that deduped by class key, so a second latency Router's selector never reached the dispatch and its cache stayed empty in real traffic. Record loggers registered with `dedupe_by_identity` in a WeakSet on the callback manager and keep identity semantics whenever they are promoted into another list, so both routers' selectors receive events. Make `Router.discard()` unregister the selectors it registered (default, group and override), and extend `_unregister_router_selectors` to the event lists, so a throwaway per-request `user_config` Router cannot leak a selector per request until MAX_CALLBACKS silently blocks later callback registration. Drop the real clock from the new regression test and drive the second-router case through `function_setup` plus the success dispatch instead of calling the selectors directly. Co-Authored-By: Claude Code --- .../logging_callback_manager.py | 17 ++- litellm/router.py | 29 +++- .../test_router_routing_groups.py | 124 ++++++++++++++++-- 3 files changed, 152 insertions(+), 18 deletions(-) diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py index 614810feae0..34d7a7edaf5 100644 --- a/litellm/litellm_core_utils/logging_callback_manager.py +++ b/litellm/litellm_core_utils/logging_callback_manager.py @@ -1,5 +1,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Final +from weakref import WeakSet import litellm from litellm._logging import verbose_logger @@ -16,6 +17,14 @@ else: _generic_api_logger_cache: Final[dict[str, GenericAPILogger]] = {} +# CustomLoggers registered with `dedupe_by_identity=True` (router strategy +# selectors) keep identity-based membership when another code path re-adds them +# to a different list. `function_setup` copies `litellm.callbacks` into the +# success/failure event lists, and the class-key dedup there would otherwise +# drop a second live instance again, leaving the newest Router's cache empty +# (issue #44575). Weakly held so a discarded logger does not leak. +_identity_deduped_loggers: Final[WeakSet[CustomLogger]] = WeakSet() + class LoggingCallbackManager: """ @@ -351,8 +360,12 @@ class LoggingCallbackManager: # Per-instance loggers are deduped by identity: their class-level key # does not distinguish two live instances, and silently dropping the # second one leaves the newest Router with an unregistered callback - # (see issue #44575). - if dedupe_by_identity: + # (see issue #44575). Once a logger is registered by identity, every + # later list it is promoted into (e.g. `function_setup` copying + # `litellm.callbacks` into the success/failure event lists) must keep + # that mode, or the second instance is dropped again there. + if dedupe_by_identity or any(existing is custom_logger for existing in _identity_deduped_loggers): + _identity_deduped_loggers.add(custom_logger) if not any(existing is custom_logger for existing in parent_list): parent_list.append(custom_logger) else: diff --git a/litellm/router.py b/litellm/router.py index 22a56fb2c1b..263afe4e4eb 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1403,6 +1403,20 @@ class Router: # Stop contributing to cost-map rebuilds straight away rather than waiting # for this router to be collected. _live_routers.discard(self) + # Drop the per-instance strategy selectors this router registered. They + # are registered by identity, so class-key dedup no longer implicitly + # caps them at one per class: without this a discarded router (e.g. the + # throwaway `user_config` Router built per request) leaks its selectors + # into every callback list for the life of the process (issue #44575). + self._unregister_router_selectors( + [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()] + + list(getattr(self, "_override_selectors", {}).values()) + + [ + selector + for selectors in getattr(self, "_group_selectors", {}).values() + for selector in selectors.values() + ] + ) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm._async_success_callback, self) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm.success_callback, self) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm._async_failure_callback, self) @@ -1539,8 +1553,11 @@ class Router: """ Drop router-owned strategy selectors from litellm's global callback lists by identity. Used before re-init (`routing_strategy_init` / - `_init_routing_groups`) so repeated `update_settings` calls don't - accumulate dead selectors that keep receiving callback events. + `_init_routing_groups`) and on `discard`, so repeated `update_settings` + calls (or discarded routers) don't accumulate dead selectors that keep + receiving callback events. Covers the success/failure event lists too: + `function_setup` promotes general callbacks into them, and the + per-instance selectors are no longer capped at one per class. """ for selector in selectors: if isinstance(selector, BaseRoutingStrategy): @@ -1552,6 +1569,14 @@ class Router: litellm.callbacks = [c for c in litellm.callbacks if id(c) not in selector_ids] if isinstance(litellm.input_callback, list): litellm.input_callback = [c for c in litellm.input_callback if id(c) not in selector_ids] + if isinstance(litellm.success_callback, list): + litellm.success_callback = [c for c in litellm.success_callback if id(c) not in selector_ids] + if isinstance(litellm.failure_callback, list): + litellm.failure_callback = [c for c in litellm.failure_callback if id(c) not in selector_ids] + if isinstance(litellm._async_success_callback, list): + litellm._async_success_callback = [c for c in litellm._async_success_callback if id(c) not in selector_ids] + if isinstance(litellm._async_failure_callback, list): + litellm._async_failure_callback = [c for c in litellm._async_failure_callback if id(c) not in selector_ids] def _apply_updated_routing_strategy_args(self) -> None: """ diff --git a/tests/unit/router_strategy/test_router_routing_groups.py b/tests/unit/router_strategy/test_router_routing_groups.py index dcb806fc641..e6544d15343 100644 --- a/tests/unit/router_strategy/test_router_routing_groups.py +++ b/tests/unit/router_strategy/test_router_routing_groups.py @@ -2256,6 +2256,20 @@ async def test_update_settings_changed_routing_strategy_args_flushes_replaced_se pass +def _reset_global_callback_lists(monkeypatch) -> None: + """Every global callback list, so a Router built here does not see selectors + promoted by an earlier test (monkeypatch would not restore those).""" + for name in ( + "callbacks", + "input_callback", + "success_callback", + "failure_callback", + "_async_success_callback", + "_async_failure_callback", + ): + monkeypatch.setattr(litellm, name, []) + + def test_two_routers_same_latency_strategy_both_register_selectors(monkeypatch): """ Two Routers with `latency-based-routing` in one process must both see their @@ -2264,8 +2278,7 @@ def test_two_routers_same_latency_strategy_both_register_selectors(monkeypatch): dedup silently dropped the second Router's selector and its latency cache never got populated (issue #44575). """ - monkeypatch.setattr(litellm, "callbacks", []) - monkeypatch.setattr(litellm, "input_callback", []) + _reset_global_callback_lists(monkeypatch) first = _build_router(routing_strategy="latency-based-routing") second = _build_router(routing_strategy="latency-based-routing") @@ -2279,19 +2292,102 @@ def test_two_routers_same_latency_strategy_both_register_selectors(monkeypatch): "second Router's latency selector was dropped by the callback dedup" ) - # Each selector feeds its own Router's cache: an event delivered through - # the global callback list must land in the emitting router's cache. - kwargs = { - "litellm_params": { - "metadata": {"model_group": "filtered-model"}, - "model_info": {"id": "deploy-1"}, - } - } - start_time = datetime.datetime.now() - for callback in litellm.callbacks: - if isinstance(callback, CustomLogger): - callback.log_success_event(kwargs, None, start_time, start_time + datetime.timedelta(seconds=0.5)) +def test_second_latency_router_receives_events_through_the_normal_logging_path(monkeypatch): + """ + Registering both selectors in `litellm.callbacks` is not enough: the normal + request path (`function_setup`) copies those callbacks into the success and + failure event lists, and that copy used to dedupe by class key and drop the + second Router's selector again. Drive `function_setup` and the success + dispatch here instead of calling the selectors directly (issue #44575). + """ + _reset_global_callback_lists(monkeypatch) + + first = _build_router(routing_strategy="latency-based-routing") + second = _build_router(routing_strategy="latency-based-routing") + second_selector = second.lowestlatency_logger + + start_time = datetime.datetime(2026, 1, 1) + logging_obj, _ = function_setup( + "completion", + Rules(), + start_time, + litellm_call_id=str(uuid.uuid4()), + messages=[{"role": "user", "content": "hi"}], + model="filtered-model", + metadata={"model_group": "filtered-model"}, + model_info={"id": "deploy-1"}, + ) + + # The promotion into the event lists is where the second instance used to be + # dropped, which the `litellm.callbacks` assertion above cannot see. + assert any(cb is second_selector for cb in litellm.success_callback), ( + "function_setup dropped the second Router's selector from the success event list" + ) + assert any(cb is second_selector for cb in litellm.failure_callback) + assert any(cb is second_selector for cb in litellm._async_success_callback) + + logging_obj.success_handler( + result=None, + start_time=start_time, + end_time=start_time + datetime.timedelta(seconds=0.5), + ) assert first.cache.get_cache("filtered-model_map") is not None assert second.cache.get_cache("filtered-model_map") is not None + + +def test_discard_unregisters_only_the_discarded_routers_selectors(monkeypatch): + """ + A discarded Router must take its identity-registered selectors (default, + group and override) out of every global callback list, or a per-request + `user_config` Router leaks a selector per request until MAX_CALLBACKS blocks + every later guardrail registration. A second, still-live Router keeps working. + """ + _reset_global_callback_lists(monkeypatch) + + first = _build_router( + routing_strategy="latency-based-routing", + routing_groups=[ + { + "group_name": "fast", + "models": ["filtered-model"], + "routing_strategy": "latency-based-routing", + } + ], + ) + second = _build_router(routing_strategy="latency-based-routing") + discarded_selectors = [ + first.lowestlatency_logger, + first._group_selectors["fast"]["latency-based-routing"], + ] + live_selector = second.lowestlatency_logger + + start_time = datetime.datetime(2026, 1, 1) + logging_obj, _ = function_setup( + "completion", + Rules(), + start_time, + litellm_call_id=str(uuid.uuid4()), + messages=[{"role": "user", "content": "hi"}], + model="filtered-model", + metadata={"model_group": "filtered-model"}, + model_info={"id": "deploy-1"}, + ) + assert all(any(cb is selector for cb in litellm.success_callback) for selector in discarded_selectors) + + first.discard() + + for selector in discarded_selectors: + assert _selector_is_not_global(selector), "discard left a selector in a global callback list" + assert any(cb is live_selector for cb in litellm.callbacks) + assert any(cb is live_selector for cb in litellm.success_callback) + + # The live Router still receives success events through the normal path. + logging_obj.success_handler( + result=None, + start_time=start_time, + end_time=start_time + datetime.timedelta(seconds=0.5), + ) + assert second.cache.get_cache("filtered-model_map") is not None + From d647a378e1b98959b88c465fdd378db5f74cc2ac Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 13:12:32 +0800 Subject: [PATCH 3/6] fix(router): avoid a double-for comprehension in discard()'s selector teardown The nested comprehension tripped the LIT014 type-discipline gate (one clause allowed). Expand it into an explicit loop, which reads more clearly anyway. Co-Authored-By: Claude Code --- litellm/router.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 263afe4e4eb..3769cfbe952 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1408,15 +1408,13 @@ class Router: # caps them at one per class: without this a discarded router (e.g. the # throwaway `user_config` Router built per request) leaks its selectors # into every callback list for the life of the process (issue #44575). - self._unregister_router_selectors( - [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()] - + list(getattr(self, "_override_selectors", {}).values()) - + [ - selector - for selectors in getattr(self, "_group_selectors", {}).values() - for selector in selectors.values() - ] - ) + selectors = [ + getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values() + ] + selectors.extend(getattr(self, "_override_selectors", {}).values()) + for group in getattr(self, "_group_selectors", {}).values(): + selectors.extend(group.values()) + self._unregister_router_selectors(selectors) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm._async_success_callback, self) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm.success_callback, self) litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm._async_failure_callback, self) From 18113c7906dcd9783dad00191eb8a36d156fca7e Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 15:27:15 +0800 Subject: [PATCH 4/6] style(router): satisfy ruff format on discard()'s selector teardown Co-Authored-By: Claude Code --- litellm/router.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 3769cfbe952..99f828c3c77 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1408,9 +1408,7 @@ class Router: # caps them at one per class: without this a discarded router (e.g. the # throwaway `user_config` Router built per request) leaks its selectors # into every callback list for the life of the process (issue #44575). - selectors = [ - getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values() - ] + selectors = [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()] selectors.extend(getattr(self, "_override_selectors", {}).values()) for group in getattr(self, "_group_selectors", {}).values(): selectors.extend(group.values()) From 024843a4bf9d49e87ad84450da2800ad5eb5e990 Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 16:42:56 +0800 Subject: [PATCH 5/6] style(router): declare discard()'s selectors list Final The LIT010 delta gate flagged the one new un-Final'd assignment this PR adds; the list is bound once and only extended in place, so Final is exact. Co-Authored-By: Claude Code --- litellm/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 99f828c3c77..3f8d557ff1a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1408,7 +1408,7 @@ class Router: # caps them at one per class: without this a discarded router (e.g. the # throwaway `user_config` Router built per request) leaks its selectors # into every callback list for the life of the process (issue #44575). - selectors = [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()] + selectors: Final = [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()] selectors.extend(getattr(self, "_override_selectors", {}).values()) for group in getattr(self, "_group_selectors", {}).values(): selectors.extend(group.values()) From de8856df73443114dc287d791faced957ef379f3 Mon Sep 17 00:00:00 2001 From: zhongerdeajie <1283667545@qq.com> Date: Sat, 10 Oct 2026 17:27:39 +0800 Subject: [PATCH 6/6] fix(router): clear discarded selectors via a callback-list helper `_unregister_router_selectors` guarded each callback list with `isinstance(..., list)` on statically-`List` globals and reached into `litellm._async_success_callback` / `_async_failure_callback` directly, which the basedpyright delta gate flags as `reportUnnecessaryIsInstance` and `reportPrivateUsage`. Route the cleanup through a new `LoggingCallbackManager.remove_callbacks_by_id`, which mirrors `remove_callback_from_all_lists` (plus `input_callback`) and filters each list in place by object id. Co-Authored-By: Claude Code --- .../logging_callback_manager.py | 31 ++++++++++++++++++- litellm/router.py | 13 +------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py index 34d7a7edaf5..f0d6dd2a5ae 100644 --- a/litellm/litellm_core_utils/logging_callback_manager.py +++ b/litellm/litellm_core_utils/logging_callback_manager.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Sequence, Set from typing import TYPE_CHECKING, Final from weakref import WeakSet @@ -451,6 +451,35 @@ class LoggingCallbackManager: ): self.remove_callback_from_list_by_object(callback_list, obj, require_self=require_self) + def remove_callbacks_by_id(self, selector_ids: Set[int]) -> None: + """ + Remove every callback whose object identity is in ``selector_ids`` from + each callback list it may have been promoted into. + + Router strategy selectors are registered by identity (see + ``add_litellm_callback(..., dedupe_by_identity=True)``), so discarding a + Router must drop them by ``id()`` rather than by class key. + """ + for callback_list in ( + litellm.callbacks, + litellm.input_callback, + litellm.success_callback, + litellm.failure_callback, + litellm._async_success_callback, # pyright: ignore[reportPrivateUsage] # internal callback list promoted into by `function_setup` + litellm._async_failure_callback, # pyright: ignore[reportPrivateUsage] # internal callback list promoted into by `function_setup` + ): + self._remove_callbacks_by_id_in_place(callback_list, selector_ids) + + def _remove_callbacks_by_id_in_place( + self, + callback_list: Sequence[CustomLogger | Callable[..., object] | str], + selector_ids: Set[int], + ) -> None: + if not isinstance(callback_list, list): # immutable sequence -> nothing to filter + return + for callback in [c for c in callback_list if id(c) in selector_ids]: + callback_list.remove(callback) + def get_active_additional_logging_utils_from_custom_logger( self, ) -> set[AdditionalLoggingUtils]: diff --git a/litellm/router.py b/litellm/router.py index 3f8d557ff1a..1b7d5df2887 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1561,18 +1561,7 @@ class Router: selector_ids: Final = {id(s) for s in selectors if s is not None} if not selector_ids: return - if isinstance(litellm.callbacks, list): - litellm.callbacks = [c for c in litellm.callbacks if id(c) not in selector_ids] - if isinstance(litellm.input_callback, list): - litellm.input_callback = [c for c in litellm.input_callback if id(c) not in selector_ids] - if isinstance(litellm.success_callback, list): - litellm.success_callback = [c for c in litellm.success_callback if id(c) not in selector_ids] - if isinstance(litellm.failure_callback, list): - litellm.failure_callback = [c for c in litellm.failure_callback if id(c) not in selector_ids] - if isinstance(litellm._async_success_callback, list): - litellm._async_success_callback = [c for c in litellm._async_success_callback if id(c) not in selector_ids] - if isinstance(litellm._async_failure_callback, list): - litellm._async_failure_callback = [c for c in litellm._async_failure_callback if id(c) not in selector_ids] + litellm.logging_callback_manager.remove_callbacks_by_id(selector_ids) def _apply_updated_routing_strategy_args(self) -> None: """