diff --git a/litellm/utils.py b/litellm/utils.py index a11c5500503e..3c19b3c33655 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3759,6 +3759,61 @@ def pre_process_optional_params(passed_params: dict, non_default_params: dict, c return optional_params +# Warn-once bookkeeping for `drop_params`. Keyed by +# (provider, model, sorted dropped param names) so a route that drops the same +# params on every request warns once, not once per call. Bounded so a +# long-lived proxy serving many models cannot grow it without limit; past the +# cap we stop recording (and therefore may repeat a warning), which is the safe +# direction to fail. +_MAX_DROPPED_PARAM_WARNINGS = 1000 +_DROPPED_PARAM_WARNINGS: set[tuple[str, str, tuple[str, ...]]] = set() + + +def _warn_dropped_params( + unsupported_params: dict, + model: str | None, + custom_llm_provider: str | None, +) -> None: + """Log once when `drop_params` discards caller-specified parameters. + + `drop_params` exists so an unsupported parameter does not fail the whole + request, and that tradeoff is right. But dropping a parameter changes + generation behaviour, and today it happens with no signal at all: a + `reasoning_effort`, `temperature` or penalty set in a proxy config simply + never reaches the provider, and nothing in the logs or the response says + so. The config and the wire disagree, silently and indefinitely. + + This is easiest to hit on a provider whose supported-param set is derived + from the model-cost map: a model absent from the map is treated as + supporting nothing beyond the base set, so the gate fails closed for any + slug newer than the map — which is a routine state, not an exotic one. + + Warns rather than debugs because the user asked for something and did not + get it; deduped so a per-request drop does not flood the log. + """ + if not unsupported_params: + return + dropped = tuple(sorted(unsupported_params.keys())) + key = (custom_llm_provider or "", model or "", dropped) + if key in _DROPPED_PARAM_WARNINGS: + return + if len(_DROPPED_PARAM_WARNINGS) < _MAX_DROPPED_PARAM_WARNINGS: + _DROPPED_PARAM_WARNINGS.add(key) + verbose_logger.warning( + "litellm.drop_params: dropping unsupported params %s for model=%s, " + "provider=%s. They will NOT reach the provider, so whatever behaviour " + "they were meant to control is unchanged. To send them anyway, pass " + "allowed_openai_params=%s in the request, or on the proxy add " + "`allowed_openai_params: %s` to that model's litellm_params in " + "config.yaml.", + list(dropped), + model, + custom_llm_provider, + list(dropped), + list(dropped), + ) + + def get_optional_params( # use the openai defaults # https://platform.openai.com/docs/api-reference/chat/create @@ -3857,6 +3912,11 @@ def _check_valid_arg(supported_params: List[str]): if unsupported_params: if litellm.drop_params is True or (drop_params is not None and drop_params is True): + _warn_dropped_params( + unsupported_params=unsupported_params, + model=model, + custom_llm_provider=custom_llm_provider, + ) for k in unsupported_params.keys(): non_default_params.pop(k, None) else: diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index a1a9448cc58d..38516bdb3d02 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4863,3 +4863,97 @@ def test_is_prompt_caching_valid_prompt_explicit_min_token_count_overrides_model is_prompt_caching_valid_prompt(model="claude-opus-4-8", messages=PROMPT_CACHE_MESSAGES, min_token_count=8192) is False ) + + +class TestDropParamsVisibility: + """`drop_params` must not discard caller-specified params in silence. + + Dropping a param changes generation behaviour, so a `reasoning_effort` or + `temperature` set in a proxy config that never reaches the provider is a + real behavioural difference with no signal attached. The warning is + deduped because the same params are dropped on every request for a given + route. + """ + + def setup_method(self): + litellm.utils._DROPPED_PARAM_WARNINGS.clear() + + def test_warns_when_an_unsupported_param_is_dropped(self): + # openrouter advertises reasoning_effort only for models flagged + # supports_reasoning in the model-cost map; an absent slug is not. + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + result = litellm.utils.get_optional_params( + model="qwen/qwen3-max", + custom_llm_provider="openrouter", + reasoning_effort="high", + drop_params=True, + ) + assert "reasoning_effort" not in result + mock_warn.assert_called_once() + assert "reasoning_effort" in str(mock_warn.call_args) + + def test_repeat_calls_warn_once(self): + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + for _ in range(5): + litellm.utils.get_optional_params( + model="qwen/qwen3-max", + custom_llm_provider="openrouter", + reasoning_effort="high", + drop_params=True, + ) + assert mock_warn.call_count == 1 + + def test_a_different_model_warns_separately(self): + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + for model in ("qwen/qwen3-max", "vendor/some-other-slug"): + litellm.utils.get_optional_params( + model=model, + custom_llm_provider="openrouter", + reasoning_effort="high", + drop_params=True, + ) + assert mock_warn.call_count == 2 + + def test_supported_params_do_not_warn(self): + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + result = litellm.utils.get_optional_params( + model="qwen/qwen3-max", + custom_llm_provider="openrouter", + temperature=0.3, + top_p=0.9, + drop_params=True, + ) + assert result["temperature"] == 0.3 + mock_warn.assert_not_called() + + def test_warning_names_both_remedies(self): + # Most people hit this on the proxy, reading a config.yaml rather than + # writing request kwargs, so a message naming only the per-request form + # reads as "not applicable to me". `allowed_openai_params` is settable + # in a model_list entry's litellm_params (LiteLLM_Params is + # ConfigDict(extra="allow")) and does reach get_optional_params from + # there, so both routes are worth naming. + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + litellm.utils.get_optional_params( + model="qwen/qwen3-max", + custom_llm_provider="openrouter", + reasoning_effort="high", + drop_params=True, + ) + message = mock_warn.call_args[0][0] + assert "allowed_openai_params" in message + assert "litellm_params" in message + assert "config.yaml" in message + + def test_no_warning_when_drop_params_is_off(self): + # Without drop_params the caller gets a loud exception instead; the + # failure is already visible, so no warning is needed. + with patch.object(litellm.utils.verbose_logger, "warning") as mock_warn: + with pytest.raises(litellm.utils.UnsupportedParamsError): + litellm.utils.get_optional_params( + model="qwen/qwen3-max", + custom_llm_provider="openrouter", + reasoning_effort="high", + drop_params=False, + ) + mock_warn.assert_not_called()