From 0d04336e8d15c09e3a8d3e634463cc32caa5dbbb Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 17:59:20 +0900 Subject: [PATCH 1/6] fix(router): mirror silent_model traffic on the generic call path (Responses API, Anthropic Messages) `silent_model` was only wired into `Router._completion` / `_acompletion`. Calls that go through `_ageneric_api_call_with_fallbacks_helper` (`/v1/responses`, Anthropic Messages, ...) never scheduled the shadow request, and the router-only `silent_model` key leaked into the provider handler's kwargs, which makes chat-bridged providers fail with `unexpected keyword argument 'silent_model'`. - add `_silent_experiment_ageneric`, the generic-path counterpart of `_silent_experiment_acompletion`; fired before the deployment's own metadata/tags are merged into kwargs, same as `_acompletion` - put the experiment marker and the overridden `model_group` in `litellm_metadata`, not `metadata`: for these call types `metadata` is the provider request-body field (OpenAI Responses `metadata`, Anthropic `metadata`, which is validated strictly) and must not carry router state - `_get_silent_experiment_kwargs` takes `metadata_variable_name` so both paths share the same isolation logic (safe_deep_copy + identity fallback) - gate mirroring on `SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES` (`aresponses`, `anthropic_messages`) so file / fine-tuning / passthrough calls served by the same helper are never replayed; skip requests whose MCP tools would be auto-executed - strip `silent_model` from the deployment params before dispatch Supersedes #31901 (same design, rebased; credit to @slxng1758). Fixes #31888, fixes #34890. Co-Authored-By: Claude Fable 5.1 --- litellm/constants.py | 5 + litellm/router.py | 86 +++++++- .../test_router_silent_experiment.py | 199 ++++++++++++++++++ 3 files changed, 282 insertions(+), 8 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 028c08a691e..0765e7d34b2 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1909,6 +1909,11 @@ DEFAULT_COMPETITOR_DISCOVERY_MODEL: Final = "gpt-4o-mini" ADVISOR_NATIVE_PROVIDERS: Final[frozenset] = frozenset({"anthropic"}) # Hard cap on advisor iterations per request to prevent runaway loops. ADVISOR_MAX_USES: Final[int] = 5 +# Generic-router call types (non chat-completion) that are safe to mirror to a +# `silent_model`. Only side-effect-free inference endpoints belong here: the same +# router helper also serves file/fine-tuning/passthrough calls that must never be +# replayed against a second deployment. +SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES: Final[frozenset] = frozenset({"aresponses", "anthropic_messages"}) # Description injected into the synthetic advisor tool definition sent to non-native providers. ADVISOR_TOOL_DESCRIPTION: Final[str] = ( "Consult a highly intelligent advisor model when you need expert guidance, " diff --git a/litellm/router.py b/litellm/router.py index 9e6db66db83..48e73612c66 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -66,6 +66,7 @@ from litellm.constants import ( ROUTING_REQUEST_TAGS_METADATA_KEY, RUNTIME_UPDATABLE_ROUTER_SETTINGS, SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, + SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES, ) from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.asyncify import run_async_function @@ -2506,7 +2507,7 @@ class Router: self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs) raise e - def _get_silent_experiment_kwargs(self, **kwargs) -> dict: + def _get_silent_experiment_kwargs(self, metadata_variable_name: str = "metadata", **kwargs) -> dict: """ Prepare kwargs for a silent experiment by ensuring isolation from the primary call. @@ -2515,6 +2516,11 @@ class Router: parent_otel_span — an OTel Span that is not deepcopy-able). Force a shallow copy of the metadata dict so mutations (model_group, is_silent_experiment) never corrupt the main call's metadata. + + `metadata_variable_name` selects the kwargs key that carries the router's own + metadata: "metadata" for chat completions, "litellm_metadata" for calls routed + through `_ageneric_api_call_with_fallbacks` (Responses API, Anthropic Messages), + where `metadata` is the provider request-body field and must not be touched. """ from litellm.litellm_core_utils.core_helpers import safe_deep_copy @@ -2525,19 +2531,19 @@ class Router: # Detect this via identity check and force a shallow copy so that setting # model_group / is_silent_experiment on the silent dict doesn't corrupt # the primary call's metadata. - original_metadata: Final = kwargs.get("metadata") - if original_metadata is not None and silent_kwargs.get("metadata") is original_metadata: - silent_kwargs["metadata"] = dict(original_metadata) + original_metadata: Final = kwargs.get(metadata_variable_name) + if original_metadata is not None and silent_kwargs.get(metadata_variable_name) is original_metadata: + silent_kwargs[metadata_variable_name] = dict(original_metadata) - if "metadata" not in silent_kwargs: - silent_kwargs["metadata"] = {} + if metadata_variable_name not in silent_kwargs: + silent_kwargs[metadata_variable_name] = {} # OTel spans are not safe to use across event loops. The silent # experiment runs in a new event loop, so strip the span to prevent # cross-loop tracing races or span corruption. - silent_kwargs["metadata"].pop("litellm_parent_otel_span", None) + silent_kwargs[metadata_variable_name].pop("litellm_parent_otel_span", None) - silent_kwargs["metadata"]["is_silent_experiment"] = True + silent_kwargs[metadata_variable_name]["is_silent_experiment"] = True # Force stream=False so the response is fully consumed and callbacks fire silent_kwargs["stream"] = False @@ -3438,6 +3444,49 @@ class Router: wrapper_ref: Final = weakref.ref(wrapped_response) return wrapped_response + async def _silent_experiment_ageneric(self, silent_model: str, original_function: Callable, **kwargs: Any) -> None: + """ + Run a silent experiment in the background for a call that goes through + `_ageneric_api_call_with_fallbacks` (Responses API, Anthropic Messages). + + Counterpart of `_silent_experiment_acompletion`. Router metadata for these + call types lives in `litellm_metadata`; `metadata` is the provider request-body + field (OpenAI Responses `metadata`, Anthropic `metadata`) and is left untouched + so the experiment marker never reaches the provider. + """ + try: + # Prevent infinite recursion if the silent model also has a silent model + for key in ("metadata", "litellm_metadata"): + marker: Final = kwargs.get(key) + if isinstance(marker, dict) and marker.get("is_silent_experiment", False): + return + + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + # Auto-executed MCP tools have side effects; never replay them + if LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools( + mcp_tools_with_litellm_proxy=kwargs.get("tools") or [] + ): + return + + verbose_router_logger.info("Starting silent experiment for model %s", silent_model) + + silent_kwargs: Final = self._get_silent_experiment_kwargs( + metadata_variable_name="litellm_metadata", **kwargs + ) + # Override model_group to correctly attribute metrics to the silent model + silent_kwargs["litellm_metadata"]["model_group"] = silent_model + + await self._ageneric_api_call_with_fallbacks( + model=silent_model, + original_function=original_function, + **silent_kwargs, + ) + except Exception as e: # noqa: BLE001 - a background mirror must never break the primary request + verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e) + async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs): """ Run a silent experiment in the background. @@ -5232,9 +5281,29 @@ class Router: return await original_generic_function(model=model, **kwargs) raise e + # Check for silent model experiment. Fire it before the deployment's own + # metadata/tags are merged into kwargs so the mirror starts from the + # caller's request, same as `_acompletion`. + silent_model: Final = deployment["litellm_params"].get("silent_model") + if ( + silent_model is not None + and getattr(original_generic_function, "__name__", None) in SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES + ): + # Mirroring traffic to a secondary model + # This is a silent experiment, so we don't want to block the primary request + asyncio.create_task( + self._silent_experiment_ageneric( + silent_model=silent_model, + original_function=original_generic_function, + **kwargs, + ) + ) + self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name=function_name) data: Final = deployment["litellm_params"].copy() + # Router-only setting: must never reach the provider handler (#34890) + data.pop("silent_model", None) model_name: Final = data["model"] self.total_calls[model_name] += 1 @@ -5254,6 +5323,7 @@ class Router: # Only set custom_llm_provider if it's not None if custom_llm_provider is not None: response_kwargs["custom_llm_provider"] = custom_llm_provider + response_kwargs.pop("silent_model", None) response = original_generic_function(**response_kwargs) diff --git a/tests/test_litellm/test_router_silent_experiment.py b/tests/test_litellm/test_router_silent_experiment.py index bfdf39bad71..a402627c7ee 100644 --- a/tests/test_litellm/test_router_silent_experiment.py +++ b/tests/test_litellm/test_router_silent_experiment.py @@ -281,3 +281,202 @@ def test_router_silent_experiment_completion(): assert silent_call[1]["model"] == "openai/gpt-4" # Verify model_group is set to the silent model name for correct metric attribution assert silent_call[1]["metadata"]["model_group"] == "silent-model" + + +# --------------------------------------------------------------------------- +# Generic router path (Responses API / Anthropic Messages) — issues #31888, #34890 +# --------------------------------------------------------------------------- + + +def _generic_silent_model_list(): + return [ + { + "model_name": "primary-model", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key", + "silent_model": "silent-model", + }, + }, + { + "model_name": "silent-model", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "fake-key", + }, + }, + ] + + +def _split_generic_calls(mock): + """Return (primary_call, silent_call) from a mocked generic handler.""" + silent = next( + (c for c in mock.call_args_list if (c.kwargs.get("litellm_metadata") or {}).get("is_silent_experiment")), + None, + ) + primary = next( + (c for c in mock.call_args_list if not (c.kwargs.get("litellm_metadata") or {}).get("is_silent_experiment")), + None, + ) + return primary, silent + + +@pytest.mark.asyncio +async def test_router_silent_experiment_aresponses(): + """ + Regression for #31888: silent_model on a deployment must fire a background + aresponses call when the primary request goes through /v1/responses. + """ + mock_aresponses = AsyncMock(return_value=MagicMock()) + mock_aresponses.__name__ = "aresponses" + + with patch.object(litellm, "aresponses", mock_aresponses): + router = Router(model_list=_generic_silent_model_list()) + await router.aresponses( + model="primary-model", + input=[{"role": "user", "content": "hi"}], + metadata={"trace": "user-supplied"}, + ) + await asyncio.sleep(0.1) + + assert mock_aresponses.call_count == 2 + primary_call, silent_call = _split_generic_calls(mock_aresponses) + assert primary_call is not None + assert silent_call is not None + + assert primary_call.kwargs["model"] == "openai/gpt-4o-mini" + assert primary_call.kwargs["litellm_metadata"]["model_group"] == "primary-model" + + assert silent_call.kwargs["model"] == "openai/gpt-4o" + # model_group is overridden so metrics/logging attribute the mirror to the silent model + assert silent_call.kwargs["litellm_metadata"]["model_group"] == "silent-model" + + # The router-only setting must never reach the provider handler (#34890) + for call in mock_aresponses.call_args_list: + assert "silent_model" not in call.kwargs + + # `metadata` is the OpenAI Responses request-body field: the marker must not be + # written there, and the caller's value must pass through untouched. + assert silent_call.kwargs["metadata"] == {"trace": "user-supplied"} + assert primary_call.kwargs["metadata"] == {"trace": "user-supplied"} + + +@pytest.mark.asyncio +async def test_router_silent_experiment_anthropic_messages(): + """ + Regression for #34890: same contract for /v1/messages. Anthropic validates the + `metadata` body field strictly, so the marker must live in litellm_metadata. + """ + mock_messages = AsyncMock(return_value=MagicMock()) + mock_messages.__name__ = "anthropic_messages" + + with patch.object(litellm, "anthropic_messages", mock_messages): + router = Router(model_list=_generic_silent_model_list()) + await router.aanthropic_messages( + model="primary-model", + messages=[{"role": "user", "content": "hi"}], + max_tokens=16, + ) + await asyncio.sleep(0.1) + + assert mock_messages.call_count == 2 + primary_call, silent_call = _split_generic_calls(mock_messages) + assert primary_call is not None + assert silent_call is not None + assert primary_call.kwargs["model"] == "openai/gpt-4o-mini" + assert silent_call.kwargs["model"] == "openai/gpt-4o" + assert silent_call.kwargs["litellm_metadata"]["model_group"] == "silent-model" + for call in mock_messages.call_args_list: + assert "silent_model" not in call.kwargs + assert "is_silent_experiment" not in (call.kwargs.get("metadata") or {}) + + +@pytest.mark.asyncio +async def test_router_silent_experiment_generic_does_not_corrupt_primary_metadata(): + """ + The primary call's litellm_metadata must not see the silent marker or the + silent model_group, even when deepcopy falls back to a shared reference. + """ + mock_aresponses = AsyncMock(return_value=MagicMock()) + mock_aresponses.__name__ = "aresponses" + + with patch.object(litellm, "aresponses", mock_aresponses): + router = Router(model_list=_generic_silent_model_list()) + litellm_metadata = { + "user_api_key_auth": _FakeUserAPIKeyAuth( + key_alias="primary-key", + parent_otel_span=_NonCopyableSpan(), + ) + } + await router.aresponses( + model="primary-model", + input=[{"role": "user", "content": "hi"}], + litellm_metadata=litellm_metadata, + ) + await asyncio.sleep(0.1) + + assert mock_aresponses.call_count == 2 + primary_call, silent_call = _split_generic_calls(mock_aresponses) + assert primary_call is not None + assert silent_call is not None + assert primary_call.kwargs["litellm_metadata"]["model_group"] == "primary-model" + assert "is_silent_experiment" not in primary_call.kwargs["litellm_metadata"] + assert silent_call.kwargs["litellm_metadata"]["model_group"] == "silent-model" + + +@pytest.mark.asyncio +async def test_router_silent_experiment_skips_non_allowlisted_generic_call_types(): + """ + Only side-effect-free inference call types are mirrored. The same generic + helper serves file/fine-tuning/passthrough calls that must not be replayed + against the silent deployment. + """ + mock_file_content = AsyncMock(return_value=MagicMock()) + mock_file_content.__name__ = "afile_content" + + with patch.object(litellm, "afile_content", mock_file_content): + router = Router(model_list=_generic_silent_model_list()) + await router.afile_content(model="primary-model", file_id="file-123") + await asyncio.sleep(0.1) + + assert mock_file_content.call_count == 1 + assert "silent_model" not in mock_file_content.call_args.kwargs + + +@pytest.mark.asyncio +async def test_silent_experiment_ageneric_no_recurse(): + """ + _silent_experiment_ageneric must not fire when the request is already a + silent experiment (marker in either litellm_metadata or metadata). + """ + router = Router(model_list=_generic_silent_model_list()) + + for key in ("litellm_metadata", "metadata"): + with patch.object(router, "_ageneric_api_call_with_fallbacks", new_callable=AsyncMock) as mock_call: + await router._silent_experiment_ageneric( + silent_model="silent-model", + original_function=litellm.aresponses, + input=[{"role": "user", "content": "hi"}], + **{key: {"is_silent_experiment": True}}, + ) + mock_call.assert_not_called() + + +@pytest.mark.asyncio +async def test_silent_experiment_ageneric_error_is_caught(): + """ + A failing mirror must never propagate to the caller. + """ + router = Router(model_list=_generic_silent_model_list()) + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new_callable=AsyncMock, + side_effect=Exception("downstream failure"), + ): + await router._silent_experiment_ageneric( + silent_model="silent-model", + original_function=litellm.aresponses, + input=[{"role": "user", "content": "hi"}], + ) From 4368dd9eca4d5a45b77eec272d4380c2e0874847 Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 18:09:27 +0900 Subject: [PATCH 2/6] fix(router): drop Any annotation on _silent_experiment_ageneric kwargs (ANN401 budget) --- litellm/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 48e73612c66..e6daa143b23 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -3444,7 +3444,7 @@ class Router: wrapper_ref: Final = weakref.ref(wrapped_response) return wrapped_response - async def _silent_experiment_ageneric(self, silent_model: str, original_function: Callable, **kwargs: Any) -> None: + async def _silent_experiment_ageneric(self, silent_model: str, original_function: Callable, **kwargs) -> None: """ Run a silent experiment in the background for a call that goes through `_ageneric_api_call_with_fallbacks` (Responses API, Anthropic Messages). From ae900b554370683a7340bdf6c51eca618a7273b1 Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 18:13:58 +0900 Subject: [PATCH 3/6] fix(router): annotate silent-experiment kwargs as object (ANN003/ANN401 budgets) --- litellm/router.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index e6daa143b23..9b3275ca8cb 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -2507,7 +2507,11 @@ class Router: self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs) raise e - def _get_silent_experiment_kwargs(self, metadata_variable_name: str = "metadata", **kwargs) -> dict: + def _get_silent_experiment_kwargs( + self, + metadata_variable_name: str = "metadata", + **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call + ) -> dict: """ Prepare kwargs for a silent experiment by ensuring isolation from the primary call. @@ -3444,7 +3448,12 @@ class Router: wrapper_ref: Final = weakref.ref(wrapped_response) return wrapped_response - async def _silent_experiment_ageneric(self, silent_model: str, original_function: Callable, **kwargs) -> None: + async def _silent_experiment_ageneric( + self, + silent_model: str, + original_function: Callable, + **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call + ) -> None: """ Run a silent experiment in the background for a call that goes through `_ageneric_api_call_with_fallbacks` (Responses API, Anthropic Messages). @@ -3466,9 +3475,8 @@ class Router: ) # Auto-executed MCP tools have side effects; never replay them - if LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools( - mcp_tools_with_litellm_proxy=kwargs.get("tools") or [] - ): + mcp_tools: Final = cast(Sequence[Mapping[str, object]], kwargs.get("tools") or []) + if LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(mcp_tools_with_litellm_proxy=mcp_tools): return verbose_router_logger.info("Starting silent experiment for model %s", silent_model) From 9707025204f91678b9ef8f03fa82235d872c77e0 Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 18:35:01 +0900 Subject: [PATCH 4/6] fix(router): keep generic silent mirror within the type/lint budgets and snapshot kwargs before scheduling - snapshot the caller's kwargs synchronously (`_silent_experiment_generic_kwargs`) before `create_task`: the task only runs once this coroutine yields, by which point `_update_kwargs_with_deployment` has merged the primary deployment's metadata and tags into kwargs in place - never mirror requests that carry `mcp_servers` or an MCP tool definition - type the silent-experiment helpers (`**kwargs: object`, typed metadata copy) and make `metadata_variable_name` positional-only so unpacked kwargs cannot bind it Co-Authored-By: Claude Fable 5.1 --- litellm/constants.py | 2 +- litellm/router.py | 161 +++++++++++------- .../test_router_silent_experiment.py | 66 +++---- 3 files changed, 136 insertions(+), 93 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 0765e7d34b2..9aae9dc7831 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1913,7 +1913,7 @@ ADVISOR_MAX_USES: Final[int] = 5 # `silent_model`. Only side-effect-free inference endpoints belong here: the same # router helper also serves file/fine-tuning/passthrough calls that must never be # replayed against a second deployment. -SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES: Final[frozenset] = frozenset({"aresponses", "anthropic_messages"}) +SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES: Final[frozenset[str]] = frozenset({"aresponses", "anthropic_messages"}) # Description injected into the synthetic advisor tool definition sent to non-native providers. ADVISOR_TOOL_DESCRIPTION: Final[str] = ( "Consult a highly intelligent advisor model when you need expert guidance, " diff --git a/litellm/router.py b/litellm/router.py index 9b3275ca8cb..792b83b7554 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -24,6 +24,7 @@ from collections import defaultdict from collections.abc import ( AsyncGenerator, AsyncIterator, + Awaitable, Callable, Generator, Iterator, @@ -679,6 +680,23 @@ def as_output_cap(value: object) -> int | None: return cap if cap >= 0 else None +def _is_silent_experiment_marker(value: object) -> bool: + """True when a metadata dict carries the silent-experiment marker.""" + return isinstance(value, Mapping) and bool( + cast(Mapping[str, object], value).get("is_silent_experiment", False) # cast-ok: narrowed by isinstance above + ) + + +def _has_mcp_tool(tools: object) -> bool: + """True when a Responses/Messages `tools` list carries an MCP tool definition.""" + if not isinstance(tools, Sequence) or isinstance(tools, str): + return False + for tool in cast(Sequence[object], tools): # cast-ok: narrowed by isinstance above + if isinstance(tool, Mapping) and cast(Mapping[str, object], tool).get("type") == "mcp": # cast-ok: narrowed + return True + return False + + class Router: model_names: set = set() cache_responses: bool | None = False @@ -2510,6 +2528,7 @@ class Router: def _get_silent_experiment_kwargs( self, metadata_variable_name: str = "metadata", + /, **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call ) -> dict: """ @@ -2528,26 +2547,28 @@ class Router: """ from litellm.litellm_core_utils.core_helpers import safe_deep_copy - silent_kwargs: Final = safe_deep_copy(kwargs) + silent_kwargs: Final[dict[str, object]] = cast( # cast-ok: safe_deep_copy is untyped # mutable-ok: fresh copy + dict[str, object], safe_deep_copy(kwargs) + ) # safe_deep_copy may fall back to the original metadata reference when # deepcopy fails (UserAPIKeyAuth.parent_otel_span is not deepcopy-able). - # Detect this via identity check and force a shallow copy so that setting - # model_group / is_silent_experiment on the silent dict doesn't corrupt - # the primary call's metadata. - original_metadata: Final = kwargs.get(metadata_variable_name) - if original_metadata is not None and silent_kwargs.get(metadata_variable_name) is original_metadata: - silent_kwargs[metadata_variable_name] = dict(original_metadata) - - if metadata_variable_name not in silent_kwargs: - silent_kwargs[metadata_variable_name] = {} + # Always shallow-copy the metadata dict so that setting model_group / + # is_silent_experiment on the silent dict doesn't corrupt the primary call's. + copied_metadata: Final = silent_kwargs.get(metadata_variable_name) + silent_metadata: Final[dict[str, object]] = ( # mutable-ok: the silent call's own metadata dict + dict(cast(Mapping[str, object], copied_metadata)) # cast-ok: narrowed by the isinstance check + if isinstance(copied_metadata, Mapping) + else {} + ) # OTel spans are not safe to use across event loops. The silent # experiment runs in a new event loop, so strip the span to prevent # cross-loop tracing races or span corruption. - silent_kwargs[metadata_variable_name].pop("litellm_parent_otel_span", None) + silent_metadata.pop("litellm_parent_otel_span", None) - silent_kwargs[metadata_variable_name]["is_silent_experiment"] = True + silent_metadata["is_silent_experiment"] = True + silent_kwargs[metadata_variable_name] = silent_metadata # Force stream=False so the response is fully consumed and callbacks fire silent_kwargs["stream"] = False @@ -2561,13 +2582,18 @@ class Router: return silent_kwargs - def _silent_experiment_completion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs): + def _silent_experiment_completion( + self, + silent_model: str, + messages: Sequence[Mapping[str, str]], + **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call + ): """ Run a silent experiment in the background (thread). """ try: # Prevent infinite recursion if silent model also has a silent model - if kwargs.get("metadata", {}).get("is_silent_experiment", False): + if _is_silent_experiment_marker(kwargs.get("metadata")): return messages = copy.deepcopy(messages) @@ -3448,45 +3474,49 @@ class Router: wrapper_ref: Final = weakref.ref(wrapped_response) return wrapped_response + def _silent_experiment_generic_kwargs( + self, + **kwargs: object, # kwargs-ok: the primary call's kwargs, snapshotted before the router mutates them + ) -> Mapping[str, object] | None: + """ + Snapshot the primary call's kwargs for a generic-path silent experiment, or + return None when the request must not be mirrored. + + Called synchronously before the mirror task is scheduled: `create_task` only + starts running once the caller yields, by which point + `_update_kwargs_with_deployment` has merged the primary deployment's metadata + and tags into `kwargs` in place. Copying here keeps the mirror on the caller's + request. Router metadata for these call types lives in `litellm_metadata`; + `metadata` is the provider request-body field (OpenAI Responses `metadata`, + Anthropic `metadata`) and is left untouched so the marker never reaches the + provider. + """ + # Prevent infinite recursion if the silent model also has a silent model + if _is_silent_experiment_marker(kwargs.get("metadata")) or _is_silent_experiment_marker( + kwargs.get("litellm_metadata") + ): + return None + # MCP tools may execute against the provider (Anthropic `mcp_servers`, Responses + # MCP tools with require_approval="never"); replaying those is a side effect. + if kwargs.get("mcp_servers") or _has_mcp_tool(kwargs.get("tools")): + return None + return self._get_silent_experiment_kwargs("litellm_metadata", **kwargs) + async def _silent_experiment_ageneric( self, silent_model: str, - original_function: Callable, - **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call + original_function: Callable[..., Awaitable[object]], + silent_kwargs: Mapping[str, object], ) -> None: """ - Run a silent experiment in the background for a call that goes through - `_ageneric_api_call_with_fallbacks` (Responses API, Anthropic Messages). - - Counterpart of `_silent_experiment_acompletion`. Router metadata for these - call types lives in `litellm_metadata`; `metadata` is the provider request-body - field (OpenAI Responses `metadata`, Anthropic `metadata`) and is left untouched - so the experiment marker never reaches the provider. + Run a generic-path silent experiment (Responses API, Anthropic Messages) in the + background. Counterpart of `_silent_experiment_acompletion`; `silent_kwargs` + comes from `_silent_experiment_generic_kwargs`. `model_group` is re-stamped to + `silent_model` by `_update_kwargs_before_fallbacks` inside the call, so metrics + attribute the mirror to the silent model. """ try: - # Prevent infinite recursion if the silent model also has a silent model - for key in ("metadata", "litellm_metadata"): - marker: Final = kwargs.get(key) - if isinstance(marker, dict) and marker.get("is_silent_experiment", False): - return - - from litellm.responses.mcp.litellm_proxy_mcp_handler import ( - LiteLLM_Proxy_MCP_Handler, - ) - - # Auto-executed MCP tools have side effects; never replay them - mcp_tools: Final = cast(Sequence[Mapping[str, object]], kwargs.get("tools") or []) - if LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(mcp_tools_with_litellm_proxy=mcp_tools): - return - verbose_router_logger.info("Starting silent experiment for model %s", silent_model) - - silent_kwargs: Final = self._get_silent_experiment_kwargs( - metadata_variable_name="litellm_metadata", **kwargs - ) - # Override model_group to correctly attribute metrics to the silent model - silent_kwargs["litellm_metadata"]["model_group"] = silent_model - await self._ageneric_api_call_with_fallbacks( model=silent_model, original_function=original_function, @@ -3495,13 +3525,18 @@ class Router: except Exception as e: # noqa: BLE001 - a background mirror must never break the primary request verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e) - async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs): + async def _silent_experiment_acompletion( + self, + silent_model: str, + messages: Sequence[Mapping[str, str]], + **kwargs: object, # kwargs-ok: the primary call's kwargs, forwarded verbatim into the silent call + ): """ Run a silent experiment in the background. """ try: # Prevent infinite recursion if silent model also has a silent model - if kwargs.get("metadata", {}).get("is_silent_experiment", False): + if _is_silent_experiment_marker(kwargs.get("metadata")): return messages = copy.deepcopy(messages) @@ -5289,23 +5324,23 @@ class Router: return await original_generic_function(model=model, **kwargs) raise e - # Check for silent model experiment. Fire it before the deployment's own - # metadata/tags are merged into kwargs so the mirror starts from the - # caller's request, same as `_acompletion`. - silent_model: Final = deployment["litellm_params"].get("silent_model") - if ( - silent_model is not None - and getattr(original_generic_function, "__name__", None) in SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES - ): - # Mirroring traffic to a secondary model - # This is a silent experiment, so we don't want to block the primary request - asyncio.create_task( - self._silent_experiment_ageneric( - silent_model=silent_model, - original_function=original_generic_function, - **kwargs, + silent_model: Final[object] = deployment["litellm_params"].get("silent_model") + generic_handler: Final[object] = cast(object, original_generic_function) # cast-ok: name only + handler_name: Final[object] = getattr(generic_handler, "__name__", None) + if isinstance(silent_model, str) and handler_name in SILENT_MODEL_MIRROR_ALLOWED_CALL_TYPES: + # Snapshot now: `_update_kwargs_with_deployment` below mutates kwargs in place, + # and the task only starts running once this coroutine yields. + silent_kwargs: Final = self._silent_experiment_generic_kwargs(**kwargs) + if silent_kwargs is not None: + asyncio.create_task( + self._silent_experiment_ageneric( + silent_model=silent_model, + original_function=cast( # cast-ok: generic handlers are async callables; the bare Callable annotation predates this + Callable[..., Awaitable[object]], original_generic_function + ), + silent_kwargs=silent_kwargs, + ) ) - ) self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name=function_name) diff --git a/tests/test_litellm/test_router_silent_experiment.py b/tests/test_litellm/test_router_silent_experiment.py index a402627c7ee..a2ed0e00d33 100644 --- a/tests/test_litellm/test_router_silent_experiment.py +++ b/tests/test_litellm/test_router_silent_experiment.py @@ -185,11 +185,7 @@ async def test_router_silent_experiment_acompletion(): # Find the silent call silent_call = next( - ( - c - for c in call_args_list - if c[1].get("metadata", {}).get("is_silent_experiment") is True - ), + (c for c in call_args_list if c[1].get("metadata", {}).get("is_silent_experiment") is True), None, ) assert silent_call is not None @@ -197,11 +193,7 @@ async def test_router_silent_experiment_acompletion(): # Find the primary call primary_call = next( - ( - c - for c in call_args_list - if not c[1].get("metadata", {}).get("is_silent_experiment") - ), + (c for c in call_args_list if not c[1].get("metadata", {}).get("is_silent_experiment")), None, ) assert primary_call is not None @@ -270,11 +262,7 @@ def test_router_silent_experiment_completion(): # Find the silent call silent_call = next( - ( - c - for c in call_args_list - if c[1].get("metadata", {}).get("is_silent_experiment") is True - ), + (c for c in call_args_list if c[1].get("metadata", {}).get("is_silent_experiment") is True), None, ) assert silent_call is not None @@ -443,23 +431,43 @@ async def test_router_silent_experiment_skips_non_allowlisted_generic_call_types assert "silent_model" not in mock_file_content.call_args.kwargs -@pytest.mark.asyncio -async def test_silent_experiment_ageneric_no_recurse(): +def test_silent_experiment_generic_kwargs_skips_recursion_and_mcp(): """ - _silent_experiment_ageneric must not fire when the request is already a - silent experiment (marker in either litellm_metadata or metadata). + The generic-path snapshot must return None when the request is already a + silent experiment (marker in either litellm_metadata or metadata) or when it + carries MCP tooling that the provider may execute. """ router = Router(model_list=_generic_silent_model_list()) + base = {"input": [{"role": "user", "content": "hi"}]} - for key in ("litellm_metadata", "metadata"): - with patch.object(router, "_ageneric_api_call_with_fallbacks", new_callable=AsyncMock) as mock_call: - await router._silent_experiment_ageneric( - silent_model="silent-model", - original_function=litellm.aresponses, - input=[{"role": "user", "content": "hi"}], - **{key: {"is_silent_experiment": True}}, - ) - mock_call.assert_not_called() + assert router._silent_experiment_generic_kwargs(**base, litellm_metadata={"is_silent_experiment": True}) is None + assert router._silent_experiment_generic_kwargs(**base, metadata={"is_silent_experiment": True}) is None + assert router._silent_experiment_generic_kwargs(**base, mcp_servers=[{"type": "url", "url": "x"}]) is None + assert ( + router._silent_experiment_generic_kwargs( + **base, tools=[{"type": "mcp", "server_label": "x", "require_approval": "never"}] + ) + is None + ) + + snapshot = router._silent_experiment_generic_kwargs(**base, tools=[{"type": "web_search"}]) + assert snapshot is not None + assert snapshot["litellm_metadata"]["is_silent_experiment"] is True + assert snapshot["stream"] is False + assert "metadata" not in snapshot + + +def test_silent_experiment_generic_kwargs_snapshots_before_mutation(): + """ + Regression: the snapshot is taken synchronously, so metadata merged into the + caller's kwargs afterwards (deployment tags etc.) never reaches the mirror. + """ + router = Router(model_list=_generic_silent_model_list()) + litellm_metadata = {"model_group": "primary-model"} + snapshot = router._silent_experiment_generic_kwargs(input="hi", litellm_metadata=litellm_metadata) + assert snapshot is not None + litellm_metadata["tags"] = ["primary-deployment-tag"] + assert "tags" not in snapshot["litellm_metadata"] @pytest.mark.asyncio @@ -478,5 +486,5 @@ async def test_silent_experiment_ageneric_error_is_caught(): await router._silent_experiment_ageneric( silent_model="silent-model", original_function=litellm.aresponses, - input=[{"role": "user", "content": "hi"}], + silent_kwargs={"input": [{"role": "user", "content": "hi"}]}, ) From 15e4944d86584101060ca51595cda163070486f6 Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 18:40:10 +0900 Subject: [PATCH 5/6] test(router): cover the silent-experiment marker and MCP-tool helpers directly --- .../test_router_silent_experiment.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/test_router_silent_experiment.py b/tests/test_litellm/test_router_silent_experiment.py index a2ed0e00d33..8cff78ebe4a 100644 --- a/tests/test_litellm/test_router_silent_experiment.py +++ b/tests/test_litellm/test_router_silent_experiment.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import litellm -from litellm.router import Router +from litellm.router import Router, _has_mcp_tool, _is_silent_experiment_marker class _NonCopyableSpan: @@ -488,3 +488,20 @@ async def test_silent_experiment_ageneric_error_is_caught(): original_function=litellm.aresponses, silent_kwargs={"input": [{"role": "user", "content": "hi"}]}, ) + + +def test_is_silent_experiment_marker(): + assert _is_silent_experiment_marker({"is_silent_experiment": True}) is True + assert _is_silent_experiment_marker({"is_silent_experiment": False}) is False + assert _is_silent_experiment_marker({}) is False + assert _is_silent_experiment_marker(None) is False + assert _is_silent_experiment_marker("is_silent_experiment") is False + + +def test_has_mcp_tool(): + assert _has_mcp_tool([{"type": "mcp", "server_label": "x"}]) is True + assert _has_mcp_tool([{"type": "web_search"}, {"type": "mcp"}]) is True + assert _has_mcp_tool([{"type": "web_search"}]) is False + assert _has_mcp_tool([]) is False + assert _has_mcp_tool(None) is False + assert _has_mcp_tool("mcp") is False From 089e316b6364cacaf50f156e6688bfb1041f956d Mon Sep 17 00:00:00 2001 From: Jaeyeon Kim Date: Fri, 11 Sep 2026 18:42:25 +0900 Subject: [PATCH 6/6] test(router): inject generic handlers via factory_function instead of patching litellm (test-quality budget) --- .../test_router_silent_experiment.py | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/tests/test_litellm/test_router_silent_experiment.py b/tests/test_litellm/test_router_silent_experiment.py index 8cff78ebe4a..4bb29fa3e7f 100644 --- a/tests/test_litellm/test_router_silent_experiment.py +++ b/tests/test_litellm/test_router_silent_experiment.py @@ -318,14 +318,14 @@ async def test_router_silent_experiment_aresponses(): mock_aresponses = AsyncMock(return_value=MagicMock()) mock_aresponses.__name__ = "aresponses" - with patch.object(litellm, "aresponses", mock_aresponses): - router = Router(model_list=_generic_silent_model_list()) - await router.aresponses( - model="primary-model", - input=[{"role": "user", "content": "hi"}], - metadata={"trace": "user-supplied"}, - ) - await asyncio.sleep(0.1) + router = Router(model_list=_generic_silent_model_list()) + router.aresponses = router.factory_function(mock_aresponses, call_type="aresponses") + await router.aresponses( + model="primary-model", + input=[{"role": "user", "content": "hi"}], + metadata={"trace": "user-supplied"}, + ) + await asyncio.sleep(0.1) assert mock_aresponses.call_count == 2 primary_call, silent_call = _split_generic_calls(mock_aresponses) @@ -358,14 +358,14 @@ async def test_router_silent_experiment_anthropic_messages(): mock_messages = AsyncMock(return_value=MagicMock()) mock_messages.__name__ = "anthropic_messages" - with patch.object(litellm, "anthropic_messages", mock_messages): - router = Router(model_list=_generic_silent_model_list()) - await router.aanthropic_messages( - model="primary-model", - messages=[{"role": "user", "content": "hi"}], - max_tokens=16, - ) - await asyncio.sleep(0.1) + router = Router(model_list=_generic_silent_model_list()) + router.aanthropic_messages = router.factory_function(mock_messages, call_type="anthropic_messages") + await router.aanthropic_messages( + model="primary-model", + messages=[{"role": "user", "content": "hi"}], + max_tokens=16, + ) + await asyncio.sleep(0.1) assert mock_messages.call_count == 2 primary_call, silent_call = _split_generic_calls(mock_messages) @@ -388,20 +388,20 @@ async def test_router_silent_experiment_generic_does_not_corrupt_primary_metadat mock_aresponses = AsyncMock(return_value=MagicMock()) mock_aresponses.__name__ = "aresponses" - with patch.object(litellm, "aresponses", mock_aresponses): - router = Router(model_list=_generic_silent_model_list()) - litellm_metadata = { - "user_api_key_auth": _FakeUserAPIKeyAuth( - key_alias="primary-key", - parent_otel_span=_NonCopyableSpan(), - ) - } - await router.aresponses( - model="primary-model", - input=[{"role": "user", "content": "hi"}], - litellm_metadata=litellm_metadata, + router = Router(model_list=_generic_silent_model_list()) + router.aresponses = router.factory_function(mock_aresponses, call_type="aresponses") + litellm_metadata = { + "user_api_key_auth": _FakeUserAPIKeyAuth( + key_alias="primary-key", + parent_otel_span=_NonCopyableSpan(), ) - await asyncio.sleep(0.1) + } + await router.aresponses( + model="primary-model", + input=[{"role": "user", "content": "hi"}], + litellm_metadata=litellm_metadata, + ) + await asyncio.sleep(0.1) assert mock_aresponses.call_count == 2 primary_call, silent_call = _split_generic_calls(mock_aresponses) @@ -419,14 +419,16 @@ async def test_router_silent_experiment_skips_non_allowlisted_generic_call_types helper serves file/fine-tuning/passthrough calls that must not be replayed against the silent deployment. """ - mock_file_content = AsyncMock(return_value=MagicMock()) + sentinel_response = MagicMock() + mock_file_content = AsyncMock(return_value=sentinel_response) mock_file_content.__name__ = "afile_content" - with patch.object(litellm, "afile_content", mock_file_content): - router = Router(model_list=_generic_silent_model_list()) - await router.afile_content(model="primary-model", file_id="file-123") - await asyncio.sleep(0.1) + router = Router(model_list=_generic_silent_model_list()) + router.afile_content = router.factory_function(mock_file_content, call_type="afile_content") + response = await router.afile_content(model="primary-model", file_id="file-123") + await asyncio.sleep(0.1) + assert response is sentinel_response assert mock_file_content.call_count == 1 assert "silent_model" not in mock_file_content.call_args.kwargs @@ -483,12 +485,14 @@ async def test_silent_experiment_ageneric_error_is_caught(): new_callable=AsyncMock, side_effect=Exception("downstream failure"), ): - await router._silent_experiment_ageneric( + result = await router._silent_experiment_ageneric( silent_model="silent-model", original_function=litellm.aresponses, silent_kwargs={"input": [{"role": "user", "content": "hi"}]}, ) + assert result is None + def test_is_silent_experiment_marker(): assert _is_silent_experiment_marker({"is_silent_experiment": True}) is True