fix(openai): drop or refuse an o-series reasoning_effort level the model map turns off

This commit is contained in:
mateo-berri 2026-11-07 19:19:40 -08:00
parent b7d579fab2
commit eb33b2462b
5 changed files with 133 additions and 2 deletions

View file

@ -19,12 +19,17 @@ from litellm import verbose_logger
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage
from litellm.utils import (
is_explicitly_disabled_factory,
supports_function_calling,
supports_parallel_function_calling,
supports_response_schema,
supports_system_messages,
)
from .gpt_5_transformation import (
get_effort_level, # pyright: ignore[reportUnknownVariableType] # its signature takes the bare reasoning_effort dict
unsupported_reasoning_effort_message,
)
from .gpt_transformation import OpenAIGPTConfig
@ -95,6 +100,19 @@ class OpenAIOSeriesConfig(OpenAIGPTConfig):
model: str,
drop_params: bool,
):
requested_effort: Final = get_effort_level(
non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
)
if requested_effort is not None and is_explicitly_disabled_factory(
model=model, custom_llm_provider=None, key=f"supports_{requested_effort}_reasoning_effort"
):
if not (litellm.drop_params or drop_params):
raise litellm.utils.UnsupportedParamsError(
message=unsupported_reasoning_effort_message("reasoning_effort", requested_effort, model),
status_code=400,
)
non_default_params.pop("reasoning_effort", None) # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
optional_params.pop("reasoning_effort", None) # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
if "max_tokens" in non_default_params:
optional_params["max_completion_tokens"] = non_default_params.pop("max_tokens")
if "temperature" in non_default_params:

View file

@ -44582,6 +44582,8 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": false,
"supports_none_reasoning_effort": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -44693,6 +44695,8 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": false,
"supports_none_reasoning_effort": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,

View file

@ -44582,6 +44582,8 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": false,
"supports_none_reasoning_effort": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -44693,6 +44695,8 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": false,
"supports_none_reasoning_effort": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,

View file

@ -1,3 +1,5 @@
from collections.abc import Mapping
from typing import Final
from unittest.mock import patch
import pytest
@ -5,6 +7,7 @@ import pytest
import litellm
from litellm import ModelResponse
from litellm.llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig
from litellm.utils import get_optional_params
import os
@ -180,3 +183,105 @@ def test_litellm_responses():
print("response: ", response)
assert isinstance(response.usage.completion_tokens_details, CompletionTokensDetails)
def _o3_row_with(flags: Mapping[str, bool]) -> dict[str, object]:
return {
**{
field: value
for field, value in litellm.model_cost["o3"].items()
if not (field.startswith("supports_") and field.endswith("_reasoning_effort"))
},
**flags,
}
@pytest.mark.parametrize(
("flags", "level", "forwarded"),
[
({}, "minimal", True),
({"supports_minimal_reasoning_effort": False}, "minimal", False),
({"supports_minimal_reasoning_effort": True}, "minimal", True),
({"supports_none_reasoning_effort": False}, "none", False),
({"supports_minimal_reasoning_effort": False}, "low", True),
({"supports_xhigh_reasoning_effort": False}, "xhigh", False),
({}, "xhigh", True),
],
)
def test_the_o_series_row_flags_alone_decide_whether_a_chat_level_is_forwarded(
local_model_cost_map: None,
monkeypatch: pytest.MonkeyPatch,
flags: dict[str, bool],
level: str,
forwarded: bool,
):
monkeypatch.setitem(litellm.model_cost, "o3", _o3_row_with(flags))
config: Final = OpenAIOSeriesConfig()
dropped: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level, "max_tokens": 10},
optional_params={},
model="o3",
drop_params=True,
)
assert dropped.get("reasoning_effort") == (level if forwarded else None)
assert dropped["max_completion_tokens"] == 10
if forwarded:
assert (
config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="o3",
drop_params=False,
)["reasoning_effort"]
== level
)
else:
with pytest.raises(litellm.utils.UnsupportedParamsError, match=f"reasoning_effort={level} is not supported"):
config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="o3",
drop_params=False,
)
@pytest.mark.parametrize("model", ["o3", "o3-2025-04-16"])
def test_o3_rows_turn_minimal_and_none_off(local_model_cost_map: None, model: str):
"""Pinned so the row-flag test above cannot go vacuous: OpenAI's chat completions endpoint answered
400 for reasoning_effort minimal and none on o3 and 200 for low, medium, high and xhigh, both through
a local proxy and straight from api.openai.com on 2026-11-07. When OpenAI adds a level, flip the rows
and this assertion together."""
row: Final = litellm.model_cost[model]
assert row["supports_minimal_reasoning_effort"] is False
assert row["supports_none_reasoning_effort"] is False
@pytest.mark.parametrize(
("non_default_params", "optional_params"),
[
({"reasoning_effort": {"effort": "minimal", "summary": "auto"}}, {}),
({}, {"reasoning_effort": "minimal"}),
],
)
def test_o3_judges_every_form_of_a_requested_level(
local_model_cost_map: None, non_default_params: dict[str, object], optional_params: dict[str, object]
):
assert "reasoning_effort" not in OpenAIOSeriesConfig().map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="o3",
drop_params=True,
)
@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"])
def test_o3_chat_request_drops_minimal_under_drop_params(local_model_cost_map: None, custom_llm_provider: str):
assert "reasoning_effort" not in get_optional_params(
model="o3", custom_llm_provider=custom_llm_provider, reasoning_effort="minimal", drop_params=True
)
@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"])
def test_o3_chat_request_refuses_minimal_without_drop_params(local_model_cost_map: None, custom_llm_provider: str):
with pytest.raises(litellm.utils.UnsupportedParamsError, match="reasoning_effort=minimal is not supported"):
get_optional_params(model="o3", custom_llm_provider=custom_llm_provider, reasoning_effort="minimal")

View file

@ -150,12 +150,12 @@ class TestNoneLevelPolarity:
def test_none_stays_opt_out_on_an_azure_model_outside_the_gpt_5_family(self):
"""AzureOpenAIGPT5Config is selected by is_model_gpt_5_model, so an azure o-series or
anthropic deployment never reaches the gate that refuses none and must keep the level."""
anthropic deployment never reaches the gate that refuses none unless its row turns none off."""
resolved = resolve_supported_reasoning_efforts(
{
"supports_reasoning": True,
"litellm_provider": "azure",
"key": "azure/o3",
"key": "azure/o-series-deployment",
"supports_max_reasoning_effort": True,
},
deployment_is_mapped=True,