fix(vertex_ai): return 400 for invalid reasoning_effort instead of 500

Both reasoning_effort mappers ended their if/elif chain in a bare ValueError.
exception_type() has no branch for ValueError, so it fell through to the shared
APIConnectionError fallback and the proxy answered a malformed client request
with a retryable HTTP 500 carrying no hint of the accepted values.

Raise UnsupportedParamsError (400) instead, listing the supported set, matching
what the Anthropic and Bedrock transforms already do and what this same file
already does at its five other param-validation sites.

This also covers 'xhigh' and 'max', which are members of litellm's own
REASONING_EFFORT literal but have no Gemini mapping, so callers bridging from
OpenAI-shaped code were hitting the 500 without typing anything wrong.

Fixes #40474

Claude-Session: https://claude.ai/code/session_01XT1qsbjLwnhiN5sQ2hNUxr
This commit is contained in:
ryan-crabbe-berri 2026-09-11 10:00:35 -07:00
parent db3338b206
commit f72b117b21
2 changed files with 85 additions and 2 deletions

View file

@ -23,6 +23,7 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET_GEMINI_2_5_FLASH_LITE,
DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET_GEMINI_2_5_PRO,
)
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.json_fragment_accumulator import JSONFragmentAccumulator
from litellm.litellm_core_utils.prompt_templates.factory import (
_encode_tool_call_id_with_signature,
@ -108,6 +109,21 @@ else:
StreamingChoices = Any
SUPPORTED_REASONING_EFFORTS: Final = ("minimal", "low", "medium", "high", "none", "disable")
def _unsupported_reasoning_effort(reasoning_effort: str) -> UnsupportedParamsError:
return UnsupportedParamsError(
message=(
f"Invalid `reasoning_effort`: {reasoning_effort!r}. "
f"Must be one of: {', '.join(repr(effort) for effort in SUPPORTED_REASONING_EFFORTS)}. "
"To drop this param, set `litellm.drop_params = True` or pass in `(.., drop_params=True)` "
"in the request - https://docs.litellm.ai/docs/completion/drop_params"
),
status_code=400,
)
class VertexAIBaseConfig:
def get_mapped_special_auth_params(self) -> dict:
"""
@ -842,7 +858,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"includeThoughts": False,
}
else:
raise ValueError(f"Invalid reasoning effort: {reasoning_effort}")
raise _unsupported_reasoning_effort(reasoning_effort)
@staticmethod
def _map_reasoning_effort_to_thinking_level(
@ -890,7 +906,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
else:
return {"thinkingLevel": "low", "includeThoughts": False}
else:
raise ValueError(f"Invalid reasoning effort: {reasoning_effort}")
raise _unsupported_reasoning_effort(reasoning_effort)
@staticmethod
def _is_thinking_budget_zero(thinking_budget: int | None) -> bool:

View file

@ -5769,3 +5769,70 @@ def test_calculate_web_search_requests_counts_unique_queries():
assert VertexGeminiConfig._calculate_web_search_requests([]) is None
assert VertexGeminiConfig._calculate_web_search_requests([{"webSearchQueries": ["", ""]}]) is None
@pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai"])
@pytest.mark.parametrize(
"model",
["gemini-2.5-flash", "gemini-3-pro-preview"],
ids=["thinking_budget_mapper", "thinking_level_mapper"],
)
@pytest.mark.parametrize("reasoning_effort", ["banana", "xhigh"])
def test_invalid_reasoning_effort_is_a_400_not_a_500(custom_llm_provider, model, reasoning_effort):
"""Regression for #40474.
Both reasoning_effort mappers used to end their if/elif chain in a bare `ValueError`, which
`exception_type()` has no branch for, so it fell through to `APIConnectionError` and the proxy
answered a malformed client request with a retryable HTTP 500. `xhigh` is covered alongside the
nonsense value because it is a member of litellm's own `REASONING_EFFORT` literal, so callers
bridging from OpenAI-shaped code reach it without typing anything wrong.
"""
from litellm.utils import get_optional_params
with pytest.raises(litellm.BadRequestError) as exc_info:
get_optional_params(
model=model,
custom_llm_provider=custom_llm_provider,
reasoning_effort=reasoning_effort,
drop_params=True,
)
assert exc_info.value.status_code == 400
message: Final = str(exc_info.value)
assert reasoning_effort in message
for supported in ("minimal", "low", "medium", "high", "none", "disable"):
assert supported in message
@pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai"])
def test_invalid_reasoning_effort_surfaces_as_400_through_completion(custom_llm_provider):
"""The same request through `completion()` must not come back as a retryable 500.
Needs no provider credentials: param mapping runs before any network call.
"""
with pytest.raises(litellm.BadRequestError) as exc_info:
completion(
model=f"{custom_llm_provider}/gemini-3-pro-preview",
messages=[{"role": "user", "content": "hi"}],
reasoning_effort="banana",
)
assert exc_info.value.status_code == 400
assert not isinstance(exc_info.value, litellm.APIConnectionError)
@pytest.mark.parametrize("model", ["gemini-2.5-flash", "gemini-3-pro-preview"])
def test_supported_reasoning_efforts_still_map(model):
"""Guards the fix against over-rejecting: every advertised value must still produce a config."""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
SUPPORTED_REASONING_EFFORTS,
)
for effort in SUPPORTED_REASONING_EFFORTS:
result: Final = VertexGeminiConfig().map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model=model,
drop_params=False,
)
assert "thinkingConfig" in result