diff --git a/litellm/router.py b/litellm/router.py index 72b8c47f744..b6dcf5f58a9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10846,11 +10846,20 @@ class Router: } ) + @staticmethod + def _declared_param_allowlist(params: Mapping[str, object]) -> frozenset[str]: + declared: Final = params.get("allowed_openai_params") + if not isinstance(declared, (list, tuple, set, frozenset)): + return frozenset() + return frozenset(entry for entry in declared if isinstance(entry, str)) + @staticmethod def _deployment_accepts_param(deployment: DeploymentTypedDict, group: str, param: str) -> bool: deployment_params: Final = deployment.get("litellm_params") if not deployment_params: return True + if param in Router._declared_param_allowlist(deployment_params): + return True deployment_model_info: Final = deployment.get("model_info") base_model: Final = ( deployment_model_info.get("base_model") if deployment_model_info else None @@ -10872,7 +10881,9 @@ class Router: return True return supported is None or param in supported - def _tier_params_the_target_accepts(self, model: str, tier_params: Mapping[str, object]) -> Mapping[str, object]: + def _tier_params_the_target_accepts( + self, model: str, tier_params: Mapping[str, object], request_kwargs: Mapping[str, object] + ) -> Mapping[str, object]: """Drop an OpenAI param that no deployment behind ``model`` declares. A tier's litellm_params are an operator override applied to every request the tier routes, @@ -10896,11 +10907,19 @@ class Router: A param survives if ANY deployment could take it, because routing has not chosen one yet, and it survives both an unresolvable provider and a group with no deployments, because a best-effort filter must never narrow what the request already did. + + allowed_openai_params is the documented escape hatch for an outdated or incomplete + supported-params list: request-time validation extends the supported list with it before + comparing. The filter asks the same question, so a param named by the allowlist on the tier + overlay, the request, or a deployment's own litellm_params is never a drop candidate. """ deployments: Final = self.get_model_list(model_name=model) or () if not deployments: return tier_params - candidates: Final = provider_rejectable_params(tier_params) - self.TIER_PARAMS_NEVER_DROPPED + allowlisted: Final = self._declared_param_allowlist(tier_params) | self._declared_param_allowlist( + request_kwargs + ) + candidates: Final = provider_rejectable_params(tier_params) - self.TIER_PARAMS_NEVER_DROPPED - allowlisted unsupported: Final = frozenset( param for param in candidates @@ -11971,7 +11990,9 @@ class Router: messages = pre_routing_hook_response.messages if pre_routing_hook_response.litellm_params: request_kwargs.update( - self._tier_params_the_target_accepts(model, pre_routing_hook_response.litellm_params) + self._tier_params_the_target_accepts( + model, pre_routing_hook_response.litellm_params, request_kwargs + ) ) ######################################################### @@ -12084,7 +12105,9 @@ class Router: messages = pre_routing_hook_response.messages if pre_routing_hook_response.litellm_params: request_kwargs.update( - self._tier_params_the_target_accepts(model, pre_routing_hook_response.litellm_params) + self._tier_params_the_target_accepts( + model, pre_routing_hook_response.litellm_params, request_kwargs + ) ) # 2. Get healthy deployments diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 68e1f9ace7d..287dddb2399 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -11216,14 +11216,14 @@ class TestTierParamsTheTargetAccepts: def test_drops_a_param_no_deployment_declares(self): router = self._router("novita/moonshotai/kimi-k3") - accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}, {}) assert accepted == {} def test_keeps_a_param_the_deployment_declares(self): router = self._router("fireworks_ai/kimi-k3") - accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}, {}) assert accepted == {"reasoning_effort": "max"} @@ -11245,7 +11245,7 @@ class TestTierParamsTheTargetAccepts: configuration the request needs while never touching what the provider would reject.""" router = self._router("novita/moonshotai/kimi-k3") - accepted = router._tier_params_the_target_accepts("tiered", {control: value, "reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("tiered", {control: value, "reasoning_effort": "max"}, {}) assert accepted == {control: value} @@ -11254,7 +11254,7 @@ class TestTierParamsTheTargetAccepts: [ ("additional_drop_params", ["seed"]), ("drop_params", True), - ("allowed_openai_params", ["reasoning_effort"]), + ("allowed_openai_params", ["seed"]), ("api_version", "2024-02-01"), ("metadata", {"tier": "complex"}), ], @@ -11265,10 +11265,48 @@ class TestTierParamsTheTargetAccepts: drop_params or additional_drop_params would silently disable the operator's sanitization.""" router = self._router("novita/moonshotai/kimi-k3") - accepted = router._tier_params_the_target_accepts("tiered", {control: value, "reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("tiered", {control: value, "reasoning_effort": "max"}, {}) assert accepted == {control: value} + def test_tier_allowlist_protects_the_param_it_names(self): + """allowed_openai_params is the documented escape hatch for an incomplete supported-params + list, and request-time validation extends the supported list with it, so a param the tier + both sets and allowlists would never 400 and must not be dropped.""" + router = self._router("novita/moonshotai/kimi-k3") + + accepted = router._tier_params_the_target_accepts( + "tiered", {"reasoning_effort": "max", "allowed_openai_params": ["reasoning_effort"]}, {} + ) + + assert accepted == {"reasoning_effort": "max", "allowed_openai_params": ["reasoning_effort"]} + + def test_request_allowlist_protects_the_param_it_names(self): + router = self._router("novita/moonshotai/kimi-k3") + + accepted = router._tier_params_the_target_accepts( + "tiered", {"reasoning_effort": "max"}, {"allowed_openai_params": ["reasoning_effort"]} + ) + + assert accepted == {"reasoning_effort": "max"} + + def test_allowlist_protects_only_the_params_it_names(self): + router = self._router("novita/moonshotai/kimi-k3") + + accepted = router._tier_params_the_target_accepts( + "tiered", {"reasoning_effort": "max", "allowed_openai_params": ["seed"]}, {} + ) + + assert accepted == {"allowed_openai_params": ["seed"]} + + def test_deployment_accepts_param_honors_deployment_allowlist(self): + deployment = { + "model_name": "x", + "litellm_params": {"model": "novita/moonshotai/kimi-k3", "allowed_openai_params": ["reasoning_effort"]}, + } + + assert litellm.Router._deployment_accepts_param(deployment, "x", "reasoning_effort") is True + def test_keeps_a_token_ceiling_the_provider_spells_differently(self): """petals lists max_tokens but not max_completion_tokens. A tier ceiling in the unsupported spelling is a cost bound: dropping it would let a caller's larger max_tokens through where @@ -11276,7 +11314,7 @@ class TestTierParamsTheTargetAccepts: router = self._router("petals/petals-team/StableBeluga2") accepted = router._tier_params_the_target_accepts( - "tiered", {"max_completion_tokens": 100, "reasoning_effort": "max"} + "tiered", {"max_completion_tokens": 100, "reasoning_effort": "max"}, {} ) assert accepted == {"max_completion_tokens": 100} @@ -11288,7 +11326,7 @@ class TestTierParamsTheTargetAccepts: router = self._router("ai21/jamba-1.5-mini") accepted = router._tier_params_the_target_accepts( - "tiered", {"extra_headers": {"x-tenant": "acme"}, "reasoning_effort": "max"} + "tiered", {"extra_headers": {"x-tenant": "acme"}, "reasoning_effort": "max"}, {} ) assert accepted == {"extra_headers": {"x-tenant": "acme"}} @@ -11302,7 +11340,7 @@ class TestTierParamsTheTargetAccepts: ] ) - accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("tiered", {"reasoning_effort": "max"}, {}) assert accepted == {"reasoning_effort": "max"} @@ -11347,6 +11385,6 @@ class TestTierParamsTheTargetAccepts: """An unresolvable target must never narrow what the request already did.""" router = self._router("fireworks_ai/kimi-k3") - accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}) + accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}, {}) assert accepted == {"reasoning_effort": "max"}