From f5b159e38d9f4326084982d5a83b44852c246f6b Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:47:43 +0000 Subject: [PATCH 1/7] fix(health): drop model capability flags from health-check probes supports_* and related model_info metadata were ending up on the probe kwargs and Bedrock rejected them as Extra inputs. Copy params before mutating and strip those keys before the call. Fixes #38941 --- litellm/proxy/health_check.py | 31 ++++++++++++++- .../litellm_utils_tests/test_health_check.py | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index b1e4f6fd9c3..cad108f6ba1 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -112,6 +112,30 @@ def _should_inject_health_check_max_tokens(model_info: Mapping[str, object], mod # Health-check modes that forward `reasoning_effort` to the provider (chat-style calls). _HEALTH_CHECK_MODES_SUPPORTING_REASONING_EFFORT: Final = frozenset((None, "chat", "completion")) +# Model-map / model_info capability metadata that must never ride on the health-check +# probe as provider request fields. Bedrock (and similar) reject them with +# ``supports_*: Extra inputs are not permitted`` (#38941). They reach the body via +# ``add_provider_specific_params_to_optional_params`` → ``{**optional_params}``. +_HEALTH_CHECK_MODEL_METADATA_KEYS: Final = frozenset( + { + "reasoning_effort_levels", + "default_reasoning_effort", + "bedrock_output_config_effort_ceiling", + "bedrock_converse_supports_strict_tools", + "thinking_always_on", + } +) + + +def _is_health_check_model_metadata_key(key: str) -> bool: + """True for model capability / catalog metadata that is not a request param.""" + return key.startswith("supports_") or key in _HEALTH_CHECK_MODEL_METADATA_KEYS + + +def _strip_model_metadata_from_health_params(litellm_params: dict) -> dict: + """Drop model_info capability keys that leaked onto the health-check probe params.""" + return {k: v for k, v in litellm_params.items() if not _is_health_check_model_metadata_key(k)} + def _get_process_rss_mb() -> float | None: """ @@ -757,6 +781,7 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di """ Update the litellm params for health check. + - copies `litellm_params` so the shared deployment dict is not mutated - merges `model_info.health_check_params` into the probe request, so a deployment whose provider requires a payload field litellm does not synthesize (e.g. `mediaSource` for Bedrock TwelveLabs Pegasus) can supply it. The dedicated knobs below are applied afterwards and win on conflict. @@ -769,7 +794,11 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di - updates the `model` param with the `health_check_model` if it exists Doc: https://docs.litellm.ai/docs/proxy/health#wildcard-routes - updates the `voice` param with the `health_check_voice` for `audio_speech` mode if it exists Doc: https://docs.litellm.ai/docs/proxy/health#text-to-speech-models - for Bedrock models with region routing (bedrock/region/model), strips the litellm routing prefix but preserves the model ID, and pins `custom_llm_provider` to `bedrock` (only when the deployment hasn't already set one, so an explicit `bedrock_converse` survives) so the bare model id still resolves to the provider (e.g. cross-region ids like `us.cohere.embed-v4:0`) + - strips model capability metadata (`supports_*`, effort ceilings, …) so it cannot leak into + the provider request body (#38941) """ + # Copy first: callers pass the live deployment litellm_params dict. + litellm_params = dict(litellm_params) mode: Final = _resolve_health_check_mode( model_info, litellm_params, # any-ok: untyped router config dict @@ -843,7 +872,7 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di "bedrock" ) - return litellm_params + return _strip_model_metadata_from_health_params(litellm_params) async def perform_health_check( diff --git a/tests/litellm_utils_tests/test_health_check.py b/tests/litellm_utils_tests/test_health_check.py index cfdddd20263..70f55944bef 100644 --- a/tests/litellm_utils_tests/test_health_check.py +++ b/tests/litellm_utils_tests/test_health_check.py @@ -832,3 +832,42 @@ async def test_health_check_with_custom_llm_provider(): # Should succeed without "LLM Provider NOT provided" error assert "error" not in response assert isinstance(response, dict) + + +def test_health_check_strips_model_capability_metadata(): + """ + Capability flags on litellm_params / health_check_params must not ride the probe. + + Bedrock rejects them as request fields (#38941): + ``supports_max_reasoning_effort: Extra inputs are not permitted``. + Also ensure the shared deployment dict is not mutated. + """ + from litellm.proxy.health_check import _update_litellm_params_for_health_check + + original = { + "model": "bedrock/anthropic.claude-3-7-sonnet-20240620-v1:0", + "api_key": "fake_key", + "supports_max_reasoning_effort": True, + "supports_xhigh_reasoning_effort": None, + "bedrock_output_config_effort_ceiling": "xhigh", + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + model_info = { + "health_check_params": { + "supports_max_reasoning_effort": True, + "mediaSource": {"s3Location": {"uri": "s3://bucket/key"}}, + }, + } + + updated = _update_litellm_params_for_health_check(model_info, original) + + assert "supports_max_reasoning_effort" not in updated + assert "supports_xhigh_reasoning_effort" not in updated + assert "bedrock_output_config_effort_ceiling" not in updated + # Legitimate probe / provider fields stay + assert "messages" in updated + assert updated["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert updated["mediaSource"] == {"s3Location": {"uri": "s3://bucket/key"}} + # Shared deployment dict must stay untouched + assert "messages" not in original + assert original["supports_max_reasoning_effort"] is True From 2800120432c0f02ac72501d9a9cd1fddcafabdfc Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:04:27 +0000 Subject: [PATCH 2/7] fix(health): satisfy type-discipline LIT001/LIT002 on probe strip Annotate the required mutable dict copy/filter so the gate does not count new dict annotations or constructions. --- litellm/proxy/health_check.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index cad108f6ba1..02674b53357 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -132,9 +132,15 @@ def _is_health_check_model_metadata_key(key: str) -> bool: return key.startswith("supports_") or key in _HEALTH_CHECK_MODEL_METADATA_KEYS -def _strip_model_metadata_from_health_params(litellm_params: dict) -> dict: +def _strip_model_metadata_from_health_params( + litellm_params: Mapping[str, object], +) -> dict: # mutable-ok: ahealth_check kwargs and LiteLLM_Params(**probe) need a mutable dict """Drop model_info capability keys that leaked onto the health-check probe params.""" - return {k: v for k, v in litellm_params.items() if not _is_health_check_model_metadata_key(k)} + return { # mutable-ok: ahealth_check kwargs and LiteLLM_Params(**probe) need a mutable dict + k: v + for k, v in litellm_params.items() + if not _is_health_check_model_metadata_key(k) + } def _get_process_rss_mb() -> float | None: @@ -798,7 +804,7 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di the provider request body (#38941) """ # Copy first: callers pass the live deployment litellm_params dict. - litellm_params = dict(litellm_params) + litellm_params = dict(litellm_params) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy mode: Final = _resolve_health_check_mode( model_info, litellm_params, # any-ok: untyped router config dict From 954c850205ac0c0543f0bda035ffa765efc83e0c Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:10:00 +0000 Subject: [PATCH 3/7] style(health_check): ruff format --- litellm/proxy/health_check.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index 02674b53357..be59b245e18 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -137,9 +137,7 @@ def _strip_model_metadata_from_health_params( ) -> dict: # mutable-ok: ahealth_check kwargs and LiteLLM_Params(**probe) need a mutable dict """Drop model_info capability keys that leaked onto the health-check probe params.""" return { # mutable-ok: ahealth_check kwargs and LiteLLM_Params(**probe) need a mutable dict - k: v - for k, v in litellm_params.items() - if not _is_health_check_model_metadata_key(k) + k: v for k, v in litellm_params.items() if not _is_health_check_model_metadata_key(k) } @@ -804,7 +802,9 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di the provider request body (#38941) """ # Copy first: callers pass the live deployment litellm_params dict. - litellm_params = dict(litellm_params) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy + litellm_params = dict( + litellm_params + ) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy mode: Final = _resolve_health_check_mode( model_info, litellm_params, # any-ok: untyped router config dict From 77f3be043eb7a742bc6ded10ef8bef18397459cf Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:17:02 +0000 Subject: [PATCH 4/7] style(health_check): put mutable-ok on dict() construction line --- litellm/proxy/health_check.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index be59b245e18..99934726532 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -802,9 +802,7 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di the provider request body (#38941) """ # Copy first: callers pass the live deployment litellm_params dict. - litellm_params = dict( - litellm_params - ) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy + litellm_params = dict(litellm_params) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy mode: Final = _resolve_health_check_mode( model_info, litellm_params, # any-ok: untyped router config dict From 5da94728b4ded429b496c1f5bcd66b53132056ad Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:55:43 +0000 Subject: [PATCH 5/7] style(health_check): ruff format --- litellm/proxy/health_check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index 99934726532..be59b245e18 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -802,7 +802,9 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di the provider request body (#38941) """ # Copy first: callers pass the live deployment litellm_params dict. - litellm_params = dict(litellm_params) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy + litellm_params = dict( + litellm_params + ) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy mode: Final = _resolve_health_check_mode( model_info, litellm_params, # any-ok: untyped router config dict From 7206a4dd88dbc2213bd916ae5129bc305de51f6d Mon Sep 17 00:00:00 2001 From: lei_lei <96427312+leilei3167@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:37:20 +0000 Subject: [PATCH 6/7] style(health_check): keep mutable-ok on probe dict copy Co-authored-by: lei_lei --- litellm/proxy/health_check.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index be59b245e18..39033cd6ebf 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -801,44 +801,41 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di - strips model capability metadata (`supports_*`, effort ceilings, …) so it cannot leak into the provider request body (#38941) """ - # Copy first: callers pass the live deployment litellm_params dict. - litellm_params = dict( - litellm_params - ) # mutable-ok: copy so probe mutations don't rewrite the shared deployment dict # rebind-ok: local probe copy + probe = dict(litellm_params) # mutable-ok: copy so probe mutations do not rewrite the shared deployment mode: Final = _resolve_health_check_mode( model_info, - litellm_params, # any-ok: untyped router config dict + probe, # any-ok: untyped router config dict ) _health_check_params: Final = model_info.get("health_check_params", None) if isinstance(_health_check_params, dict): - litellm_params.update(_health_check_params) + probe.update(_health_check_params) elif _health_check_params is not None: logger.warning( "health_check_params for model %s is a %s, expected a dict. Ignoring it.", - litellm_params.get("model"), + probe.get("model"), type(_health_check_params).__name__, ) - litellm_params["messages"] = _get_random_llm_message() + probe["messages"] = _get_random_llm_message() if _should_inject_health_check_max_tokens( model_info, mode, # any-ok: untyped router config dict ): - _resolved_max_tokens: Final = _resolve_health_check_max_tokens(model_info, litellm_params) + _resolved_max_tokens: Final = _resolve_health_check_max_tokens(model_info, probe) if _resolved_max_tokens is not None: - litellm_params["max_tokens"] = _resolved_max_tokens + probe["max_tokens"] = _resolved_max_tokens # Per-model reasoning effort for health checks only (e.g. reasoning_effort=none). if mode in _HEALTH_CHECK_MODES_SUPPORTING_REASONING_EFFORT: _hc_reasoning_effort: Final = model_info.get("health_check_reasoning_effort", None) if _hc_reasoning_effort is not None: - litellm_params["reasoning_effort"] = _hc_reasoning_effort + probe["reasoning_effort"] = _hc_reasoning_effort _health_check_model: Final = model_info.get("health_check_model", None) if _health_check_model is not None: - litellm_params["model"] = _health_check_model + probe["model"] = _health_check_model if mode == "audio_speech": - litellm_params["voice"] = model_info.get("health_check_voice", "alloy") + probe["voice"] = model_info.get("health_check_voice", "alloy") # Handle Bedrock region routing format: bedrock/region/model # This is needed because health checks bypass get_llm_provider() for the model param @@ -849,10 +846,10 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di # Issue: Stripping these breaks AWS requirement for inference profile IDs # # Must also preserve route prefixes (converse/, invoke/) and handlers (llama/, deepseek_r1/, etc.) - if litellm_params["model"].startswith("bedrock/"): + if probe["model"].startswith("bedrock/"): from litellm.llms.bedrock.common_utils import BedrockModelInfo - model = litellm_params["model"] + model = probe["model"] # Strip only the bedrock/ prefix (preserve routes like converse/, invoke/) model = model.removeprefix("bedrock/") # len("bedrock/") = 8 @@ -872,13 +869,13 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di filtered_parts.append(part) model = "/".join(filtered_parts) - litellm_params["model"] = model - if not litellm_params.get("custom_llm_provider"): # any-ok: untyped router dict - litellm_params["custom_llm_provider"] = ( # any-ok: untyped router dict + probe["model"] = model + if not probe.get("custom_llm_provider"): # any-ok: untyped router dict + probe["custom_llm_provider"] = ( # any-ok: untyped router dict "bedrock" ) - return _strip_model_metadata_from_health_params(litellm_params) + return _strip_model_metadata_from_health_params(probe) async def perform_health_check( From 42b7c99414c8abcf2083eb4a8f28372d84aa7daf Mon Sep 17 00:00:00 2001 From: lei_lei Date: Sun, 13 Sep 2026 07:45:35 +0000 Subject: [PATCH 7/7] chore(ui): regenerate schema.d.ts for OpenAPI sync --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..839aa52fa84 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16886,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)