diff --git a/litellm/llms/openai/chat/o_series_transformation.py b/litellm/llms/openai/chat/o_series_transformation.py index c7552d47985..b3f19357a33 100644 --- a/litellm/llms/openai/chat/o_series_transformation.py +++ b/litellm/llms/openai/chat/o_series_transformation.py @@ -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: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 891e247747e..9b3ab7fb3d6 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 891e247747e..9b3ab7fb3d6 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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, diff --git a/tests/unit/llms/openai/test_o_series_transformation.py b/tests/unit/llms/openai/test_o_series_transformation.py index c62523cf756..4b908b551e4 100644 --- a/tests/unit/llms/openai/test_o_series_transformation.py +++ b/tests/unit/llms/openai/test_o_series_transformation.py @@ -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") diff --git a/tests/unit/router_utils/test_reasoning_effort_capability.py b/tests/unit/router_utils/test_reasoning_effort_capability.py index 2226b1f39df..8e561855207 100644 --- a/tests/unit/router_utils/test_reasoning_effort_capability.py +++ b/tests/unit/router_utils/test_reasoning_effort_capability.py @@ -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,