From 6c1364512bfde3032a9791068b71363964255fbe Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Wed, 17 Jun 2026 12:25:41 +0800 Subject: [PATCH 1/3] fix(core_helpers): extract internal-param filter to a registry + register in all_litellm_params Pull the inline internal-param set in filter_internal_params into a module-level LITELLM_INTERNAL_PARAMS constant and register those mcp-handler keys in all_litellm_params (per review). Filter stays scoped to that subset: fallback_utils feeds the result into litellm.acompletion, so filtering the full all_litellm_params would strip api_key/num_retries; stream_chunk_size is read downstream (converse streaming) so it is deliberately not filtered. Co-Authored-By: Chenglun Hu --- litellm/litellm_core_utils/core_helpers.py | 29 +++--- litellm/types/utils.py | 4 + .../litellm_core_utils/test_core_helpers.py | 97 +++++++++++++------ 3 files changed, 90 insertions(+), 40 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b095b4b12c6..1b278d518b5 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -675,12 +675,27 @@ def filter_exceptions_from_params(data: object, max_depth: int = 20) -> Any: return data +# MCP handler plumbing — internal params that must never be forwarded as +# provider kwargs. Also registered in `all_litellm_params` (the repo-wide param +# registry, per review), but the filter is scoped to this subset: callers like +# fallback_utils pass the result straight back into `litellm.acompletion`, so +# filtering the full `all_litellm_params` (api_key/num_retries/...) would break +# the call, and knobs like stream_chunk_size are read downstream (converse +# streaming) so they are not internal. See #30301. +LITELLM_INTERNAL_PARAMS: set = { + "skip_mcp_handler", + "mcp_handler_context", + "_skip_mcp_handler", +} + + def filter_internal_params(data: dict, additional_internal_params: set | None = None) -> dict: """ Filter out LiteLLM internal parameters that shouldn't be sent to provider APIs. - This removes internal/MCP-related parameters that are used by LiteLLM internally - but should not be included in API requests to providers. + The base set is `LITELLM_INTERNAL_PARAMS` (pure plumbing, also registered in + `all_litellm_params`); callers may pass extra names via + `additional_internal_params` for provider-specific knobs. Args: data: Dictionary of parameters to filter @@ -692,18 +707,10 @@ def filter_internal_params(data: dict, additional_internal_params: set | None = if not isinstance(data, dict): return data - # Known internal parameters that should never be sent to provider APIs - internal_params: Final = { - "skip_mcp_handler", - "mcp_handler_context", - "_skip_mcp_handler", - } - - # Add any additional internal params if provided + internal_params = set(LITELLM_INTERNAL_PARAMS) if additional_internal_params: internal_params.update(additional_internal_params) - # Filter out internal parameters return {k: v for k, v in data.items() if k not in internal_params} diff --git a/litellm/types/utils.py b/litellm/types/utils.py index caf88e5d517..f049ade0410 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4095,6 +4095,10 @@ all_litellm_params = ( "adaptive_router_default_model", "quality_router_config", "quality_router_default_model", + # MCP handler plumbing — internal, must never leak into a provider body (#30301) + "skip_mcp_handler", + "mcp_handler_context", + "_skip_mcp_handler", ] + list(StandardCallbackDynamicParams.__annotations__.keys()) + list(CustomPricingLiteLLMParams.model_fields.keys()) diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 2a6dd347d5f..88328454b12 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -7,11 +7,13 @@ import pytest from litellm.litellm_core_utils.core_helpers import ( _FINISH_REASON_MAP, + LITELLM_INTERNAL_PARAMS, RESPONSE_COST_HEADER, bind_budget_reservation_to_callbacks, budget_reservation_from_metadata, drop_params_env_flag, drop_params_flag, + filter_internal_params, get_or_create_metadata_bucket, get_provider_response_headers_from_hidden_params, map_finish_reason, @@ -22,7 +24,11 @@ from litellm.litellm_core_utils.core_helpers import ( unbind_budget_reservation_from_callbacks, ) from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import ImageResponse, TranscriptionResponse +from litellm.types.utils import ( + ImageResponse, + TranscriptionResponse, + all_litellm_params, +) class TestBudgetReservationBinding: @@ -183,9 +189,7 @@ class TestMapFinishReasonAnthropic: ("content_filtered", "content_filter"), ], ) - def test_anthropic_finish_reasons( - self, provider_reason: str, expected: str - ) -> None: + def test_anthropic_finish_reasons(self, provider_reason: str, expected: str) -> None: assert map_finish_reason(provider_reason) == expected def test_refusal(self): @@ -256,9 +260,7 @@ class TestMapFinishReasonZhipu: class TestMapFinishReasonOpenAIPassthrough: - @pytest.mark.parametrize( - "reason", ["stop", "length", "tool_calls", "function_call", "content_filter"] - ) + @pytest.mark.parametrize("reason", ["stop", "length", "tool_calls", "function_call", "content_filter"]) def test_openai_values_pass_through(self, reason): assert map_finish_reason(reason) == reason @@ -269,9 +271,7 @@ class TestMapFinishReasonGenericError: assert map_finish_reason("error") == "stop" def test_lowercase_error_does_not_warn(self, mocker): - warn = mocker.patch( - "litellm.litellm_core_utils.core_helpers.verbose_logger.warning" - ) + warn = mocker.patch("litellm.litellm_core_utils.core_helpers.verbose_logger.warning") assert map_finish_reason("error") == "stop" warn.assert_not_called() @@ -289,8 +289,7 @@ class TestFinishReasonMapOutputsAreValid: """Every value in _FINISH_REASON_MAP must be a valid OpenAI finish reason.""" for provider_reason, openai_reason in _FINISH_REASON_MAP.items(): assert openai_reason in VALID_OPENAI_FINISH_REASONS, ( - f"Mapped value '{openai_reason}' (from '{provider_reason}') " - f"is not a valid OpenAI finish reason" + f"Mapped value '{openai_reason}' (from '{provider_reason}') is not a valid OpenAI finish reason" ) @@ -300,28 +299,18 @@ class TestRedactNestedMatchAndRegexKeys: "assessments": [ { "sensitiveInformationPolicy": { - "piiEntities": [ - {"type": "NAME", "match": "secret-name", "action": "BLOCKED"} - ] - }, - "wordPolicy": { - "customWords": [{"match": "badword", "action": "BLOCKED"}] + "piiEntities": [{"type": "NAME", "match": "secret-name", "action": "BLOCKED"}] }, + "wordPolicy": {"customWords": [{"match": "badword", "action": "BLOCKED"}]}, } ], "regex": "should-redact-key-named-regex", } out = redact_nested_match_and_regex_keys(payload) - assert out["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0][ - "match" - ] == "[REDACTED]" - assert out["assessments"][0]["wordPolicy"]["customWords"][0]["match"] == ( - "[REDACTED]" - ) + assert out["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "[REDACTED]" + assert out["assessments"][0]["wordPolicy"]["customWords"][0]["match"] == ("[REDACTED]") assert out["regex"] == "[REDACTED]" - assert payload["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][ - 0 - ]["match"] == "secret-name" + assert payload["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "secret-name" def test_passes_through_none_and_str(self): assert redact_nested_match_and_regex_keys(None) is None @@ -484,13 +473,17 @@ class TestIsExpectedClientError: assert is_expected_client_error(over_budget) is True litellm_limit = RateLimitError( - message="key over rpm", llm_provider="anthropic", model="claude-haiku-4-5", + message="key over rpm", + llm_provider="anthropic", + model="claude-haiku-4-5", category=RateLimitErrorCategory.LITELLM_RATE_LIMIT, ) assert is_expected_client_error(litellm_limit) is True vendor_limit = RateLimitError( - message="rate limited upstream", llm_provider="anthropic", model="claude-haiku-4-5", + message="rate limited upstream", + llm_provider="anthropic", + model="claude-haiku-4-5", category=RateLimitErrorCategory.VENDOR_RATE_LIMIT, ) assert is_expected_client_error(vendor_limit) is False @@ -557,3 +550,49 @@ class TestProviderResponseHeadersInHiddenParams: assert get_provider_response_headers_from_hidden_params(sibling) is None assert "additional_headers" not in sibling._hidden_params + + +class TestFilterInternalParams: + # MCP handler plumbing keys, also registered in all_litellm_params (#30301) + INTERNAL_KEYS = { + "skip_mcp_handler", + "mcp_handler_context", + "_skip_mcp_handler", + } + + def test_registry_strips_every_known_internal_key(self): + seeded = {k: "leak" for k in self.INTERNAL_KEYS} + seeded["model"] = "gpt-4" + seeded["temperature"] = 0.2 + out = filter_internal_params(seeded) + for k in self.INTERNAL_KEYS: + assert k not in out, f"{k} leaked through filter_internal_params" + assert out == {"model": "gpt-4", "temperature": 0.2} + + def test_internal_keys_are_registered_in_all_litellm_params(self): + for k in self.INTERNAL_KEYS: + assert k in all_litellm_params, f"{k} missing from all_litellm_params" + + def test_stream_chunk_size_is_not_filtered(self): + # stream_chunk_size is read downstream (converse streaming), not internal + assert filter_internal_params({"stream_chunk_size": 2048}) == {"stream_chunk_size": 2048} + + def test_additional_internal_params_layer_on_top(self): + out = filter_internal_params( + {"keep": 1, "skip_mcp_handler": 2, "provider_only": 3}, + additional_internal_params={"provider_only"}, + ) + assert out == {"keep": 1} + + def test_registry_not_mutated_by_additional_params(self): + baseline = set(LITELLM_INTERNAL_PARAMS) + filter_internal_params({"x": 1}, additional_internal_params={"adhoc_key"}) + assert LITELLM_INTERNAL_PARAMS == baseline + + def test_non_dict_passes_through(self): + assert filter_internal_params("not-a-dict") == "not-a-dict" + assert filter_internal_params([1, 2, 3]) == [1, 2, 3] + + def test_existing_mcp_keys_still_filtered(self): + out = filter_internal_params({"skip_mcp_handler": True, "mcp_handler_context": {}, "model": "gpt-4"}) + assert out == {"model": "gpt-4"} From 6a67fda42e258363ec6311ea3384597c77f6b124 Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Thu, 18 Jun 2026 09:58:26 +0800 Subject: [PATCH 2/3] rename constant to MCP_INTERNAL_REQUEST_KEYS per review (avoid all_litellm_params name echo) --- litellm/litellm_core_utils/core_helpers.py | 6 +++--- .../test_litellm/litellm_core_utils/test_core_helpers.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 1b278d518b5..94b75ff0a76 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -682,7 +682,7 @@ def filter_exceptions_from_params(data: object, max_depth: int = 20) -> Any: # filtering the full `all_litellm_params` (api_key/num_retries/...) would break # the call, and knobs like stream_chunk_size are read downstream (converse # streaming) so they are not internal. See #30301. -LITELLM_INTERNAL_PARAMS: set = { +MCP_INTERNAL_REQUEST_KEYS: set = { "skip_mcp_handler", "mcp_handler_context", "_skip_mcp_handler", @@ -693,7 +693,7 @@ def filter_internal_params(data: dict, additional_internal_params: set | None = """ Filter out LiteLLM internal parameters that shouldn't be sent to provider APIs. - The base set is `LITELLM_INTERNAL_PARAMS` (pure plumbing, also registered in + The base set is `MCP_INTERNAL_REQUEST_KEYS` (pure plumbing, also registered in `all_litellm_params`); callers may pass extra names via `additional_internal_params` for provider-specific knobs. @@ -707,7 +707,7 @@ def filter_internal_params(data: dict, additional_internal_params: set | None = if not isinstance(data, dict): return data - internal_params = set(LITELLM_INTERNAL_PARAMS) + internal_params = set(MCP_INTERNAL_REQUEST_KEYS) if additional_internal_params: internal_params.update(additional_internal_params) diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 88328454b12..ec890d9fe9e 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -7,12 +7,16 @@ import pytest from litellm.litellm_core_utils.core_helpers import ( _FINISH_REASON_MAP, +<<<<<<< HEAD LITELLM_INTERNAL_PARAMS, RESPONSE_COST_HEADER, bind_budget_reservation_to_callbacks, budget_reservation_from_metadata, drop_params_env_flag, drop_params_flag, +======= + MCP_INTERNAL_REQUEST_KEYS, +>>>>>>> 8957cc20e7 (rename constant to MCP_INTERNAL_REQUEST_KEYS per review (avoid all_litellm_params name echo)) filter_internal_params, get_or_create_metadata_bucket, get_provider_response_headers_from_hidden_params, @@ -585,9 +589,9 @@ class TestFilterInternalParams: assert out == {"keep": 1} def test_registry_not_mutated_by_additional_params(self): - baseline = set(LITELLM_INTERNAL_PARAMS) + baseline = set(MCP_INTERNAL_REQUEST_KEYS) filter_internal_params({"x": 1}, additional_internal_params={"adhoc_key"}) - assert LITELLM_INTERNAL_PARAMS == baseline + assert MCP_INTERNAL_REQUEST_KEYS == baseline def test_non_dict_passes_through(self): assert filter_internal_params("not-a-dict") == "not-a-dict" From 2f74a5136079985d4ad39bf9d35010d115c19bb0 Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Wed, 15 Jul 2026 22:37:29 +0800 Subject: [PATCH 3/3] chore: ruff format core_helpers + test --- litellm/litellm_core_utils/core_helpers.py | 16 ++++++++-------- .../litellm_core_utils/test_core_helpers.py | 6 +----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 94b75ff0a76..15497a3323c 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -682,11 +682,13 @@ def filter_exceptions_from_params(data: object, max_depth: int = 20) -> Any: # filtering the full `all_litellm_params` (api_key/num_retries/...) would break # the call, and knobs like stream_chunk_size are read downstream (converse # streaming) so they are not internal. See #30301. -MCP_INTERNAL_REQUEST_KEYS: set = { - "skip_mcp_handler", - "mcp_handler_context", - "_skip_mcp_handler", -} +MCP_INTERNAL_REQUEST_KEYS: Final = frozenset( + ( + "skip_mcp_handler", + "mcp_handler_context", + "_skip_mcp_handler", + ) +) def filter_internal_params(data: dict, additional_internal_params: set | None = None) -> dict: @@ -707,9 +709,7 @@ def filter_internal_params(data: dict, additional_internal_params: set | None = if not isinstance(data, dict): return data - internal_params = set(MCP_INTERNAL_REQUEST_KEYS) - if additional_internal_params: - internal_params.update(additional_internal_params) + internal_params: Final = MCP_INTERNAL_REQUEST_KEYS | frozenset(additional_internal_params or ()) return {k: v for k, v in data.items() if k not in internal_params} diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index ec890d9fe9e..602678c62c1 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -7,16 +7,12 @@ import pytest from litellm.litellm_core_utils.core_helpers import ( _FINISH_REASON_MAP, -<<<<<<< HEAD - LITELLM_INTERNAL_PARAMS, + MCP_INTERNAL_REQUEST_KEYS, RESPONSE_COST_HEADER, bind_budget_reservation_to_callbacks, budget_reservation_from_metadata, drop_params_env_flag, drop_params_flag, -======= - MCP_INTERNAL_REQUEST_KEYS, ->>>>>>> 8957cc20e7 (rename constant to MCP_INTERNAL_REQUEST_KEYS per review (avoid all_litellm_params name echo)) filter_internal_params, get_or_create_metadata_bucket, get_provider_response_headers_from_hidden_params,