From 28096412efa16406edbe4c2b8299f234b267012c Mon Sep 17 00:00:00 2001 From: yuee3 <118178092+yuee3@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:59:52 +0800 Subject: [PATCH] fix(router): track total/success/fail call metrics on sync completion path --- litellm/router_python_core.py | 6 ++++ tests/unit/test_router/test_router.py | 43 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/litellm/router_python_core.py b/litellm/router_python_core.py index 2235abc9897..c85492d9bfb 100644 --- a/litellm/router_python_core.py +++ b/litellm/router_python_core.py @@ -3551,6 +3551,8 @@ class PythonCore(DeprecatedRouterMembers): else: model_client = potential_model_client + self.total_calls[model_name] += 1 + ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit) ## only run if model group given, not model id if model in self._core_model_names or not self._core_has_model_id(model): @@ -3585,6 +3587,8 @@ class PythonCore(DeprecatedRouterMembers): ): response.fetch_sync_stream() + self.success_calls[model_name] += 1 + # Wrap streaming responses so MidStreamFallbackError (raised # during iteration) triggers the Router's fallback chain. if isinstance(response, CustomStreamWrapper): @@ -3598,6 +3602,8 @@ class PythonCore(DeprecatedRouterMembers): return response except Exception as e: verbose_router_logger.info("litellm.completion(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e) + if model_name is not None: + self.fail_calls[model_name] += 1 # Set per-deployment num_retries on exception for retry logic if deployment is not None: self._set_deployment_num_retries_on_exception(e, deployment) diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 4f35efdfd6e..c009f08ae12 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -27369,6 +27369,7 @@ class TestRouterIndexManagement: f"{'=' * 70}\n" ) + def test_model_names_is_set(self): """Verify that model_names uses a set for O(1) lookups, not a list (O(n))""" router = Router(model_list=[]) @@ -32027,3 +32028,45 @@ def test_image_routing_inspects_native_request_content_without_translation(reque original: Final = copy.deepcopy(request_kwargs) assert _candidate_ids(router, request_kwargs=request_kwargs) == ["vision"] assert request_kwargs == original + + +def test_sync_completion_tracks_router_metrics(): + """ + Regression: sync router.completion() must update router.total_calls / + router.success_calls / router.fail_calls, same as router.acompletion() does. + + Before the fix, _completion() (the sync entry point that completion() + reaches through function_with_fallbacks -> async_function_with_retries) + never touched any of the three counters, so the router metrics stayed at + zero for every sync call. + """ + router = Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o", "num_retries": 0}, + } + ], + num_retries=0, + ) + + ## success: total/success track the deployment model + router.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + mock_response="Hello", + ) + assert router.total_calls["openai/gpt-4o"] == 1 + assert router.success_calls["openai/gpt-4o"] == 1 + assert router.fail_calls.get("openai/gpt-4o", 0) == 0 + + ## failure: fail tracks the deployment model + with pytest.raises(Exception): + router.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + mock_response="litellm.InternalServerError", + ) + assert router.fail_calls["openai/gpt-4o"] == 1 + assert router.total_calls["openai/gpt-4o"] == 2 + assert router.success_calls["openai/gpt-4o"] == 1