diff --git a/litellm/llms/azure/chat/gpt_5_transformation.py b/litellm/llms/azure/chat/gpt_5_transformation.py index a23fffb5744..a9b6c27a0df 100644 --- a/litellm/llms/azure/chat/gpt_5_transformation.py +++ b/litellm/llms/azure/chat/gpt_5_transformation.py @@ -1,12 +1,10 @@ """Support for Azure OpenAI gpt-5 model family.""" -from typing import Final +from typing import ClassVar, Final import litellm -from litellm.exceptions import UnsupportedParamsError from litellm.llms.openai.chat.gpt_5_transformation import ( OpenAIGPT5Config, - get_effort_level, is_gpt_reasoning_series_name, ) from litellm.types.llms.openai import AllMessageValues @@ -15,9 +13,16 @@ from .gpt_transformation import AzureOpenAIConfig class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): - """Azure specific handling for gpt-5 models.""" + """Azure specific handling for gpt-5 models. + + reasoning_effort none joins xhigh as opt-in here: Azure rejected it on every gpt-5 + deployment before gpt-5.1 (https://github.com/BerriAI/litellm/issues/16704), and a deployment + name the map never heard of must not forward it. See + https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning + """ GPT5_SERIES_ROUTE = "gpt5_series/" + OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh", "none")) @classmethod def _model_map_lookup_name(cls, model: str) -> str: @@ -27,11 +32,18 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): model_prices_and_context_window.json; ``azure/gpt-5.1`` is. Overriding the shared resolver rather than one lookup means the supports, explicitly-disabled and default-effort answers all read the same entry. + + An ``azure_ai/`` name reaches this config when ``get_llm_provider`` re-routes a Foundry + deployment on an OpenAI-v1 host to the azure provider with the prefix intact. It is the + same product the azure_ai config reads ``azure_ai/`` for, so that row answers when + the map carries it, and the azure/ twin when it does not. """ if model.startswith(cls.GPT5_SERIES_ROUTE): return "azure/" + model[len(cls.GPT5_SERIES_ROUTE) :] if model.startswith("azure/"): return model + if model.startswith("azure_ai/"): + return model if model in litellm.model_cost else "azure/" + model.removeprefix("azure_ai/") return "azure/" + model @classmethod @@ -75,33 +87,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): drop_params: bool, api_version: str = "", ) -> dict: - reasoning_effort_value = non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort") - effective_effort: Final = get_effort_level(reasoning_effort_value) - - # gpt-5.1/5.2/5.4 support reasoning_effort='none', but other gpt-5 models don't - # See: https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning - supports_none: Final = self._supports_reasoning_effort_level(model, "none") - - if effective_effort == "none" and not supports_none: - if litellm.drop_params is True or (drop_params is not None and drop_params is True): - non_default_params = non_default_params.copy() - optional_params = optional_params.copy() - if get_effort_level(non_default_params.get("reasoning_effort")) == "none": - non_default_params.pop("reasoning_effort") - if get_effort_level(optional_params.get("reasoning_effort")) == "none": - optional_params.pop("reasoning_effort") - else: - raise UnsupportedParamsError( - status_code=400, - message=( - "Azure OpenAI does not support reasoning_effort='none' for this model. " - "To drop this parameter, set `litellm.drop_params=True` or for proxy:\n\n" - "`litellm_settings:\n drop_params: true`\n" - "Issue: https://github.com/BerriAI/litellm/issues/16704" - ), - ) - - result: Final = OpenAIGPT5Config.map_openai_params( + return OpenAIGPT5Config.map_openai_params( self, non_default_params=non_default_params, optional_params=optional_params, @@ -109,17 +95,6 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): drop_params=drop_params, ) - # Only drop reasoning_effort='none' for models that don't support it - result_effort: Final = get_effort_level(result.get("reasoning_effort")) - if result_effort == "none" and not supports_none: - result.pop("reasoning_effort") - - # Azure gpt-5.4+ with tools + reasoning_effort is now routed to the - # Responses API bridge (same as OpenAI), so we no longer need to drop - # reasoning_effort here. See: responses_api_bridge_check() in main.py. - - return result - def transform_request( self, model: str, diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 75714d5253b..74e65572809 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -65,10 +65,18 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.AZURE + @staticmethod + def _is_gpt_5_model(model: str) -> bool: + return AzureOpenAIGPT5Config.is_model_gpt_5_model(model) + @staticmethod def _supports_reasoning_effort_none(model: str) -> bool: return AzureOpenAIGPT5Config.supports_reasoning_effort_level(model, "none") + @staticmethod + def _effort_level_is_disabled(model: str, level: str) -> bool: + return AzureOpenAIGPT5Config.row_disables_reasoning_effort_level(model, level) + @staticmethod def _effort_resolves_to_none(model: str, effort: str | None) -> bool: return AzureOpenAIGPT5Config.effort_resolves_to_none(model, effort) diff --git a/litellm/llms/azure_ai/responses/transformation.py b/litellm/llms/azure_ai/responses/transformation.py index 2721f26e0fb..d23de05e148 100644 --- a/litellm/llms/azure_ai/responses/transformation.py +++ b/litellm/llms/azure_ai/responses/transformation.py @@ -3,6 +3,7 @@ from typing import Final import httpx from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig +from litellm.llms.azure_ai.chat.transformation import AzureAIGPT5Config from litellm.llms.azure_ai.common_utils import ( AzureFoundryModelInfo, api_key_header_for_base, @@ -27,6 +28,18 @@ class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.AZURE_AI + @staticmethod + def _supports_reasoning_effort_none(model: str) -> bool: + return AzureAIGPT5Config.supports_reasoning_effort_level(model, "none") + + @staticmethod + def _effort_level_is_disabled(model: str, level: str) -> bool: + return AzureAIGPT5Config.row_disables_reasoning_effort_level(model, level) + + @staticmethod + def _effort_resolves_to_none(model: str, effort: str | None) -> bool: + return AzureAIGPT5Config.effort_resolves_to_none(model, effort) + def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict: params: Final = litellm_params or GenericLiteLLMParams() auth_headers: Final = get_azure_ai_auth_headers( diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 5bf19844287..8d68e7bbad5 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -1,7 +1,7 @@ """Support for OpenAI gpt-5 model family.""" import re -from typing import Final +from typing import ClassVar, Final import litellm from litellm.utils import ( @@ -71,6 +71,10 @@ _get_effort_level = get_effort_level GPT_REASONING_SERIES_MARKERS: Final = ("gpt-5", "gpt-6") +def unsupported_reasoning_effort_message(param: str, level: str, model: str) -> str: + return f"{param}={level} is not supported for {model}. To drop unsupported params set `litellm.drop_params = True`" + + def is_gpt_reasoning_series_name(model: str) -> bool: normalized: Final = model.split("/")[-1] return any(marker in model for marker in GPT_REASONING_SERIES_MARKERS) and "gpt-5-chat" not in normalized @@ -86,6 +90,9 @@ class OpenAIGPT5Config(OpenAIGPTConfig): - Support for GPT-5-Codex models optimized for code generation. """ + OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh",)) + ROUTE_SPECIFIC_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("max",)) + @classmethod def is_model_gpt_5_model(cls, model: str) -> bool: return is_gpt_reasoning_series_name(model) @@ -205,15 +212,10 @@ class OpenAIGPT5Config(OpenAIGPTConfig): return False @classmethod - def _is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool: + def is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool: """Return True only when the model map explicitly sets the capability to False. - Unlike ``_supports_reasoning_effort_level`` (which requires an explicit True), - this method returns True only when ``supports_{level}_reasoning_effort`` is - explicitly set to ``False`` in the model map. A missing key is treated as - supported (i.e. this method returns False = not disabled). - - Use this for opt-out checks where unknown models should be allowed through. + A missing key is not a disable. Unknown models pass through. """ return is_explicitly_disabled_factory( model=cls._model_map_lookup_name(model), @@ -221,6 +223,39 @@ class OpenAIGPT5Config(OpenAIGPTConfig): key=f"supports_{level}_reasoning_effort", ) + @classmethod + def _is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool: + return cls.is_reasoning_effort_level_explicitly_disabled(model, level) + + @classmethod + def refuses_reasoning_effort_level(cls, model: str, level: str) -> bool: + """The chat gate for any requested effort level, read off the model's map row alone. + + An opt-in level (xhigh here; Azure adds none) is refused unless the row says true, since + the providers rejected it on most of the family when it arrived. Every other level is + refused only when the row says false: a model the map never heard of, or a row without the + flag, keeps forwarding what it was sent, and a level with no flag at all (medium, high) is + never refused. So a new model in the family needs its map row and no code change, the + row's supports__reasoning_effort flags being the whole contract. + + A route-specific level (max) is never refused by either gate: the same model honors it + on Responses and refuses it on Chat Completions, so one row flag cannot gate it on + either route. Chat forwards it for the Responses bridge and a plain chat call gets the + provider's own 400, exactly as before the gate read every level. + """ + if level in cls.ROUTE_SPECIFIC_REASONING_EFFORTS: + return False + if level in cls.OPT_IN_REASONING_EFFORTS: + return not cls._supports_reasoning_effort_level(model, level) + return cls.is_reasoning_effort_level_explicitly_disabled(model, level) + + @classmethod + def row_disables_reasoning_effort_level(cls, model: str, level: str) -> bool: + """The Responses gate: opt-out for every level except the route-specific ones.""" + if level in cls.ROUTE_SPECIFIC_REASONING_EFFORTS: + return False + return cls.is_reasoning_effort_level_explicitly_disabled(model, level) + def get_supported_openai_params(self, model: str) -> list: if self.is_model_gpt_5_search_model(model): return [ @@ -281,12 +316,12 @@ class OpenAIGPT5Config(OpenAIGPTConfig): drop_params=drop_params, ) - # Get raw reasoning_effort and effective effort level for all guards. - # Use effective_effort (extracted string) for xhigh validation, "none" checks, and - # tool/sampling guards — dict inputs like {"effort": "none", "summary": "detailed"} - # must be treated as effort="none" to avoid incorrect tool-drop or sampling errors. - raw_reasoning_effort = non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort") - effective_effort: Final = get_effort_level(raw_reasoning_effort) + # Dict inputs like {"effort": "none", "summary": "detailed"} are judged by their effort + # string so the gate, the sampling guard, and the temperature guard all see one level. + raw_reasoning_effort: Final = non_default_params.get("reasoning_effort") or optional_params.get( + "reasoning_effort" + ) + requested_effort: Final = get_effort_level(raw_reasoning_effort) # Normalize dict reasoning_effort to string for Chat Completions API. # Example: {"effort": "high", "summary": "detailed"} -> "high" @@ -298,31 +333,20 @@ class OpenAIGPT5Config(OpenAIGPTConfig): if "reasoning_effort" in optional_params: optional_params["reasoning_effort"] = normalized - if effective_effort == "xhigh": - # xhigh is an opt-in capability: only allow if model explicitly supports it. - if not self._supports_reasoning_effort_level(model, effective_effort): - if litellm.drop_params or drop_params: - non_default_params.pop("reasoning_effort", None) - optional_params.pop("reasoning_effort", None) - else: - raise litellm.utils.UnsupportedParamsError( - message=(f"reasoning_effort={effective_effort} is not supported for this model."), - status_code=400, - ) - elif effective_effort in ("minimal", "low"): - # minimal/low are opt-out: unknown models pass through; only block when - # the model map explicitly sets supports_{level}_reasoning_effort=false. - # Example: gpt-5.5-pro only accepts {medium, high, xhigh}, so it sets - # supports_low_reasoning_effort=false (and supports_minimal=false). - if self._is_reasoning_effort_level_explicitly_disabled(model, effective_effort): - if litellm.drop_params or drop_params: - non_default_params.pop("reasoning_effort", None) - optional_params.pop("reasoning_effort", None) - else: - raise litellm.utils.UnsupportedParamsError( - message=(f"reasoning_effort={effective_effort} is not supported for this model."), - status_code=400, - ) + if requested_effort is not None and self.refuses_reasoning_effort_level(model, requested_effort): + if litellm.drop_params or drop_params: + non_default_params.pop("reasoning_effort", None) + optional_params.pop("reasoning_effort", None) + else: + raise litellm.utils.UnsupportedParamsError( + message=unsupported_reasoning_effort_message("reasoning_effort", requested_effort, model), + status_code=400, + ) + + # The level the provider will see: None once dropped, so the guards below judge by the model's default effort + effective_effort: Final = get_effort_level( + non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort") + ) ################################################################ # max_tokens is not supported for gpt-5 models on OpenAI API diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 768d89b9a97..464dc799c16 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -25,7 +25,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig -from litellm.llms.openai.chat.gpt_5_transformation import is_gpt_reasoning_series_name +from litellm.llms.openai.chat.gpt_5_transformation import ( + is_gpt_reasoning_series_name, + unsupported_reasoning_effort_message, +) from litellm.responses.litellm_completion_transformation.custom_tools import TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE from litellm.responses.litellm_completion_transformation.reasoning_items import is_litellm_minted_reasoning_item from litellm.responses.litellm_completion_transformation.tool_search import ( @@ -278,6 +281,20 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return supports_none_reasoning_effort(model=model, custom_llm_provider=None) + @staticmethod + def _effort_level_is_disabled(model: str, level: str) -> bool: + """Whether the model's cost-map row turns this effort level off. + + The same row the chat gate reads, so a level the row refuses is refused on both wires + and a new model needs its row, not code. This surface stays opt-out for every level, + xhigh included, since it never refused a level the row omits; max is the exception, a + level the row cannot gate because the same model takes it here and refuses it on chat. + Azure overrides this so a bare deployment name reads the azure/ entry. + """ + from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config + + return OpenAIGPT5Config.row_disables_reasoning_effort_level(model, level) + @staticmethod def _effort_resolves_to_none(model: str, effort: str | None) -> bool: """Whether this request's reasoning effort ends up as "none", the one condition @@ -384,7 +401,25 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): lookup_name: Final = self._model_map_lookup_name(model) if self._is_gpt_5_model(model=lookup_name): reasoning: Final = params.get("reasoning") or {} - effort: Final = reasoning.get("effort") if isinstance(reasoning, dict) else None + requested_effort: Final = reasoning.get("effort") if isinstance(reasoning, dict) else None + if ( + isinstance(reasoning, dict) + and isinstance(requested_effort, str) + and self._effort_level_is_disabled(lookup_name, requested_effort) + ): + if not (drop_params or litellm.drop_params): + raise litellm.UnsupportedParamsError( + message=unsupported_reasoning_effort_message("reasoning.effort", requested_effort, model), + status_code=400, + ) + remaining: Final = reasoning.copy() + remaining.pop("effort", None) + if remaining: + params["reasoning"] = remaining + else: + params.pop("reasoning", None) + forwarded_reasoning: Final = params.get("reasoning") + effort: Final = forwarded_reasoning.get("effort") if isinstance(forwarded_reasoning, dict) else None supports_none: Final = self._supports_reasoning_effort_none(model=lookup_name) effort_is_none: Final = supports_none and self._effort_resolves_to_none(lookup_name, effort) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index de76b6e1bdf..6e573402427 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -3895,7 +3895,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_cache_breakpoint": true, @@ -8409,7 +8409,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -8458,7 +8458,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -9252,7 +9252,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -33352,9 +33352,9 @@ "supports_response_schema": true, "supports_vision": true, "supports_reasoning": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false }, "chatgpt/gpt-5.3-codex-spark": { "litellm_provider": "chatgpt", @@ -37807,9 +37807,9 @@ "supports_tool_search": true, "supports_vision": true, "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, "deprecation_date": "2027-04-01" }, "gpt-5.3-chat-latest": { @@ -69624,7 +69624,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/litellm/responses/bridge_check.py b/litellm/responses/bridge_check.py index 120a0d7c828..60318d6b184 100644 --- a/litellm/responses/bridge_check.py +++ b/litellm/responses/bridge_check.py @@ -5,6 +5,7 @@ from urllib.parse import urlsplit import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.azure_ai.chat.transformation import AzureAIGPT5Config from litellm.llms.azure_ai.common_utils import ( azure_ai_supports_native_responses, foundry_chat_rejects_function_tools_while_reasoning, @@ -133,6 +134,16 @@ def chat_target_accepts_reasoning_summary(model: str, custom_llm_provider: str | return chat_config is not None and chat_config.accepts_reasoning_summary_in_reasoning_effort +def _chat_gate_refuses_reasoning_effort_none(model: str, custom_llm_provider: str) -> bool: + match custom_llm_provider: + case "azure": + return litellm.AzureOpenAIGPT5Config.refuses_reasoning_effort_level(model, "none") + case "azure_ai": + return AzureAIGPT5Config.refuses_reasoning_effort_level(model, "none") + case _: + return OpenAIGPT5Config.refuses_reasoning_effort_level(model, "none") + + def responses_api_bridge_check( model: str, custom_llm_provider: str, @@ -183,7 +194,9 @@ def responses_api_bridge_check( # server-side), and Chat Completions rejects function tools whenever reasoning is # on ("Function tools with reasoning_effort are not supported ... use # /v1/responses or set reasoning_effort to 'none'"), so only an explicit - # ``"none"`` keeps the request chat-servable. Custom (grammar) tools are served + # ``"none"`` the chat gate forwards keeps the request chat-servable: a ``none`` + # the model's map row refuses is dropped (or refused) before the call, which + # leaves the provider's default effort in force. Custom (grammar) tools are served # natively by Chat Completions with reasoning on, so custom-only requests stay on # chat and keep their native custom tool_call response shape. # - The UNSET-effort arm only fires against endpoints known to enforce that @@ -213,10 +226,13 @@ def responses_api_bridge_check( (tool.get("type") if isinstance(tool, dict) else getattr(tool, "type", None)) == "tool_search" for tool in (tools or ()) ) - if isinstance(reasoning_effort, dict): - reasoning_active = reasoning_effort.get("effort") != "none" or reasoning_effort.get("summary") is not None - else: - reasoning_active = reasoning_effort != "none" + requested_effort: Final = reasoning_effort.get("effort") if isinstance(reasoning_effort, dict) else reasoning_effort + requested_summary: Final = reasoning_effort.get("summary") if isinstance(reasoning_effort, dict) else None + reasoning_active: Final = ( + requested_effort != "none" + or requested_summary is not None + or _chat_gate_refuses_reasoning_effort_none(model, custom_llm_provider) + ) # The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com # host (the default URL or a PrivateLink hostname such as .privatelink.api.openai.com) and # by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default diff --git a/litellm/router_utils/reasoning_effort_capability.py b/litellm/router_utils/reasoning_effort_capability.py index 200de29b664..d660adf05a6 100644 --- a/litellm/router_utils/reasoning_effort_capability.py +++ b/litellm/router_utils/reasoning_effort_capability.py @@ -1,20 +1,17 @@ """Resolve which reasoning_effort values a deployment, and by intersection a model group, accepts. -An entry that states its levels outright in reasoning_effort_levels is read first and wins -whole, for a model whose set the per-level flags cannot express: Kimi K3 takes low, high and max, -and no flag can drop medium because medium has none. Every other entry answers through the -supports_*_reasoning_effort flags below, whose polarity mirrors how a request path reads that same -flag. medium and high are unconditional for a reasoning model. minimal and low are opt-out: -openai/chat/gpt_5_transformation.py refuses them only when the map says false. xhigh and max are -opt-in. none is opt-out everywhere except the azure gpt-5 family, whose config raises -UnsupportedParamsError without an explicit true. +An entry that states its levels outright in reasoning_effort_levels wins whole, for a model whose set +the per-level flags cannot express (Kimi K3 takes low, high and max, and no flag can drop medium). +Every other entry answers through its supports_*_reasoning_effort flags, read with the polarity the +chat gate uses: medium and high are unconditional, minimal and low are opt-out (refused only on an +explicit false), xhigh is opt-in, and none is opt-out except on the azure gpt-5 family, whose chat +config refuses it without an explicit true. -xhigh is gated on the request path by the openai and azure gpt-5 configs. max is not gated there at -all: outside the gpt-6-astra rows every entry carrying supports_max_reasoning_effort is Claude-family, -and anthropic/chat/transformation.py gates max on the output_config path while its reasoning_effort -path maps any level to a thinking budget. Making max opt-in is a deliberate trade, then, since an -explicit flag is the only signal that the tier is a real one rather than litellm rounding the level -to a budget, and a missing flag costs advisory metadata rather than a rejected request. +The Responses gate reads none, minimal, low and xhigh as opt-out. Neither gate judges max, which +Chat Completions refuses and Responses honors on the gpt-6 family, so its flag is advisory and +advertised opt-in: outside the gpt-6 rows every entry carrying supports_max_reasoning_effort is +Claude-family, where anthropic/chat/transformation.py gates max on the output_config path, so an +explicit flag is the only signal that the tier is real. A deployment the map describes with no effort flags at all resolves to None rather than to the opt-out defaults. 689 of the map's 854 reasoning entries carry no flag, and the o-series, xai and diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index de76b6e1bdf..6e573402427 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -3895,7 +3895,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_cache_breakpoint": true, @@ -8409,7 +8409,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -8458,7 +8458,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -9252,7 +9252,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -33352,9 +33352,9 @@ "supports_response_schema": true, "supports_vision": true, "supports_reasoning": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false }, "chatgpt/gpt-5.3-codex-spark": { "litellm_provider": "chatgpt", @@ -37807,9 +37807,9 @@ "supports_tool_search": true, "supports_vision": true, "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, "deprecation_date": "2027-04-01" }, "gpt-5.3-chat-latest": { @@ -69624,7 +69624,7 @@ "supports_max_reasoning_effort": false, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": true, + "supports_none_reasoning_effort": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py b/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py index 389398dcbf4..7a4b5badb3a 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py @@ -321,6 +321,23 @@ class TestTheNormalizedTierIsTheTierSent: ): assert _reasoning_effort_sent(model, provider, effort) == expected + @pytest.mark.parametrize( + "model, provider", + [ + ("gpt-5.5", "azure_ai"), + ("azure_ai/gpt-5.5", "azure_ai"), + ("gpt-5.6", "azure"), + ("us/gpt-5.6", "azure"), + ], + ) + def test_an_azure_hosted_deployment_without_the_flag_drops_to_the_tier_it_accepts( + self, local_model_cost_map, model, provider + ): + """Chat Completions answers ``max`` on gpt-5.6 with a 400 naming ``none`` through ``xhigh`` + (OpenAI, 2026-10-01) and none of these Azure rows declares the level, so the adapter sends + the tier below instead of the rejected one.""" + assert _reasoning_effort_sent(model, provider, "max") == "xhigh" + @pytest.mark.parametrize( "model, provider", [ @@ -328,13 +345,16 @@ class TestTheNormalizedTierIsTheTierSent: ("azure_ai/gpt-6-astra", "azure_ai"), ("gpt-6-astra", "azure"), ("us/gpt-6-astra", "azure"), + ("eu/gpt-6-astra", "azure"), ], ) - def test_an_azure_hosted_astra_deployment_drops_to_the_tier_it_accepts( + def test_an_azure_hosted_astra_deployment_drops_to_the_tier_its_chat_route_accepts( self, local_model_cost_map: None, model: str, provider: str ) -> None: - """The deployment answers ``max`` with a 400 naming ``none`` through ``xhigh``, so the rows - say so and the adapter sends the tier below instead of the rejected one.""" + """A Foundry gpt-6-astra answers a Chat Completions ``max`` with a 400 naming low through + xhigh while honoring the same level on the Responses route (2026-10-01). The row's flag is + route-blind, so it keeps max off and the adapter, which cannot know which route the call + takes, sends the tier below instead of the level chat rejects.""" assert _reasoning_effort_sent(model, provider, "max") == "xhigh" def test_the_openai_hosted_twin_still_sends_max(self, local_model_cost_map): diff --git a/tests/unit/llms/azure/chat/test_azure_gpt5_transformation.py b/tests/unit/llms/azure/chat/test_azure_gpt5_transformation.py index 5fcd1f41789..80328022f61 100644 --- a/tests/unit/llms/azure/chat/test_azure_gpt5_transformation.py +++ b/tests/unit/llms/azure/chat/test_azure_gpt5_transformation.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Final import pytest @@ -369,17 +370,22 @@ def test_azure_gpt_6_astra_takes_the_reasoning_series_request_shape(): model="gpt-6-astra", custom_llm_provider="azure", max_tokens=100, - reasoning_effort="max", + reasoning_effort="xhigh", ) assert params["max_completion_tokens"] == 100 assert "max_tokens" not in params - assert params["reasoning_effort"] == "max" + assert params["reasoning_effort"] == "xhigh" -@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) -def test_azure_gpt6_astra_reasoning_effort_none_unlocks_temperature(config: AzureOpenAIGPT5Config, model: str): - """Foundry's gpt-6-astra accepts reasoning_effort='none' and, only then, a non-default - temperature (verified live against a Foundry deployment), unlike OpenAI's gpt-6-astra.""" +@pytest.mark.parametrize("model", ["azure/gpt-6-sol", "azure/us/gpt-6-sol"]) +def test_azure_reasoning_effort_none_unlocks_temperature_off_the_azure_row( + config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch, model: str +): + """A deployment whose azure/ row takes none accepts a non-default temperature with it, read off + that row even when OpenAI's twin row says the model refuses none.""" + monkeypatch.setitem( + litellm.model_cost, "gpt-6-sol", {**litellm.model_cost["gpt-6-sol"], "supports_none_reasoning_effort": False} + ) params = config.map_openai_params( non_default_params={"temperature": 0.2, "reasoning_effort": "none"}, optional_params={}, @@ -391,14 +397,127 @@ def test_azure_gpt6_astra_reasoning_effort_none_unlocks_temperature(config: Azur assert params["reasoning_effort"] == "none" -@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) -def test_azure_gpt6_astra_rejects_reasoning_effort_minimal(config: AzureOpenAIGPT5Config, model: str): - """Foundry's gpt-6-astra lists none, low, medium, high, xhigh and max but not minimal.""" - with pytest.raises(litellm.utils.UnsupportedParamsError): - config.map_openai_params( - non_default_params={"reasoning_effort": "minimal"}, +def _azure_chat_row_forwards(row: Mapping[str, object], level: str) -> bool: + """Azure chat polarity: max is never gated, xhigh and none need an explicit true, every other + level only has to not be false.""" + if level == "max": + return True + flag: Final = row.get(f"supports_{level}_reasoning_effort") + return flag is True if level in ("xhigh", "none") else flag is not False + + +@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra", "azure/eu/gpt-6-astra"]) +@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_azure_gpt6_astra_forwards_exactly_the_effort_levels_its_map_row_allows( + config: AzureOpenAIGPT5Config, model: str, level: str +): + """The azure/ row is the whole contract for a Foundry deployment. A live Foundry gpt-6-astra + (model gpt-6-astra-2026-09-03) answered reasoning_effort none with a 400 naming low, medium, + high and xhigh on 2026-10-01, so its row turns none and minimal off. The row keeps max off too, + since chat refuses it, but no gate reads that flag: the Responses route honors max, so chat + forwards it for the bridge and a plain chat call gets the provider's own 400.""" + forwarded: Final = _azure_chat_row_forwards(litellm.model_cost[model], level) + dropped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model=model, + drop_params=True, + api_version="2025-04-01-preview", + ) + assert ("reasoning_effort" in dropped) is forwarded + if forwarded: + kept: Final = config.map_openai_params( + non_default_params={"reasoning_effort": level}, optional_params={}, model=model, drop_params=False, api_version="2025-04-01-preview", ) + assert kept["reasoning_effort"] == level + else: + with pytest.raises(litellm.utils.UnsupportedParamsError): + config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model=model, + drop_params=False, + api_version="2025-04-01-preview", + ) + + +@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra", "azure/eu/gpt-6-astra"]) +def test_azure_gpt6_astra_row_turns_none_and_minimal_off(model: str): + """Pinned on purpose so the row-derived test above cannot go vacuous: a Foundry gpt-6-astra + deployment refused none on chat and on Responses on 2026-10-01 (400 unsupported_value naming + low, medium, high, xhigh), and minimal the same way; max it refused on chat while honoring it + on Responses, so the route-blind flag stays off. When Azure adds a level, flip the row and + this assertion together.""" + row: Final = litellm.model_cost[model] + assert row["supports_none_reasoning_effort"] is False + assert row["supports_minimal_reasoning_effort"] is False + assert row["supports_max_reasoning_effort"] is False + + +def test_azure_chat_gate_reads_the_foundry_row_when_azure_ai_prefix_survives_provider_remap( + config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch +): + """A Foundry deployment on an OpenAI-v1 host is re-routed to the azure provider with its + azure_ai/ prefix intact, and this config then gates it. The rows are made to disagree on low, + a level neither real row flags, so the prefixed name has to read azure_ai/gpt-6-sol while the + bare deployment name keeps reading azure/gpt-6-sol.""" + monkeypatch.setitem( + litellm.model_cost, + "azure_ai/gpt-6-sol", + {**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False}, + ) + monkeypatch.setitem( + litellm.model_cost, + "azure/gpt-6-sol", + {**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True}, + ) + prefixed: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "low"}, + optional_params={}, + model="azure_ai/gpt-6-sol", + drop_params=True, + api_version="2025-04-01-preview", + ) + assert "reasoning_effort" not in prefixed + bare: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "low"}, + optional_params={}, + model="gpt-6-sol", + drop_params=True, + api_version="2025-04-01-preview", + ) + assert bare["reasoning_effort"] == "low" + + +def test_azure_chat_gate_falls_back_to_the_azure_row_for_an_azure_ai_name_the_map_lacks( + config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delitem(litellm.model_cost, "azure_ai/gpt-6-sol") + monkeypatch.setitem( + litellm.model_cost, + "azure/gpt-6-sol", + {**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": False}, + ) + dropped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "low"}, + optional_params={}, + model="azure_ai/gpt-6-sol", + drop_params=True, + api_version="2025-04-01-preview", + ) + assert "reasoning_effort" not in dropped + + +@pytest.mark.parametrize("key", ["azure/gpt-6.1-sol", "azure/gpt-6.1-sol-2026-09-29", "azure_ai/gpt-6.1-sol"]) +def test_azure_gpt61_sol_rows_turn_none_off(key: str): + """Pinned on purpose, as the astra row above: a Foundry gpt-6.1-sol deployment (model + gpt-6.1-sol-2026-09-29) refused none on chat and on Responses on 2026-10-02 (400 unsupported_value + naming low, medium, high, xhigh, and max on Responses), and minimal the same way, while these rows + still said none was on. When Azure adds a level, flip the row and this assertion together.""" + row: Final = litellm.model_cost[key] + assert row["supports_none_reasoning_effort"] is False + assert row["supports_minimal_reasoning_effort"] is False diff --git a/tests/unit/llms/azure/response/test_azure_transformation.py b/tests/unit/llms/azure/response/test_azure_transformation.py index 9b4c618c78b..7ed98f6c705 100644 --- a/tests/unit/llms/azure/response/test_azure_transformation.py +++ b/tests/unit/llms/azure/response/test_azure_transformation.py @@ -727,16 +727,21 @@ def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None: litellm.add_known_models(model_cost_map=litellm.model_cost) -def test_azure_responses_gpt6_astra_reasoning_effort_none_unlocks_temperature(local_model_cost_map: None): - """Foundry's gpt-6-astra accepts reasoning.effort='none' with a non-default temperature - while OpenAI's gpt-6-astra does not, so the gate must read the azure/ cost-map entry - for the bare deployment name rather than OpenAI's.""" +def test_azure_responses_reasoning_effort_none_unlocks_temperature_off_the_azure_row( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + """The gate reads the azure/ cost-map entry for the bare deployment name rather than OpenAI's + twin: with OpenAI's gpt-6-sol row turned off for none, azure/gpt-6-sol's row still lets none + through and, only then, a non-default temperature.""" + monkeypatch.setitem( + litellm.model_cost, "gpt-6-sol", {**litellm.model_cost["gpt-6-sol"], "supports_none_reasoning_effort": False} + ) params = AzureOpenAIResponsesAPIConfig().map_openai_params( response_api_optional_params=ResponsesAPIOptionalRequestParams( temperature=0.2, reasoning={"effort": "none"}, ), - model="gpt-6-astra", + model="gpt-6-sol", drop_params=False, ) assert params["temperature"] == 0.2 @@ -755,6 +760,116 @@ def test_azure_responses_gpt6_astra_rejects_temperature_while_reasoning(local_mo ) +@pytest.mark.parametrize("model", ["gpt-6-astra", "gpt-6.1-sol"]) +@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_azure_responses_gpt6_effort_levels_follow_the_azure_row(local_model_cost_map: None, model: str, level: str): + """A bare deployment name reads the azure/ row, opt-out for every level but max, so the none + and minimal Foundry refuses on this route (live 400s on 2026-10-01 for gpt-6-astra and 2026-10-02 + for gpt-6.1-sol) are dropped or refused here while the max it honors goes through whatever the + route-blind flag says.""" + forwarded = ( + level == "max" or litellm.model_cost[f"azure/{model}"].get(f"supports_{level}_reasoning_effort") is not False + ) + dropped = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}), + model=model, + drop_params=True, + ) + assert ("reasoning" in dropped) is forwarded + if forwarded: + kept = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}), + model=model, + drop_params=False, + ) + assert kept["reasoning"] == {"effort": level} + else: + with pytest.raises(litellm.UnsupportedParamsError): + AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}), + model=model, + drop_params=False, + ) + + +def test_azure_gpt6_astra_keeps_max_on_this_route_whatever_its_row_says(local_model_cost_map: None): + """Pinned on purpose: the row-derived test above exempts max by hand, so this one has to fail + if the gate ever reads the flag. A Foundry gpt-6-astra deployment answered reasoning.effort max + through /v1/responses with 200, the effort echoed back and reasoning tokens billed, on + 2026-10-01, while chat refuses the same level, as OpenAI's chat does. The row keeps max off for + the adapter and the picker, and this gate still forwards it even under drop_params.""" + assert litellm.model_cost["azure/gpt-6-astra"]["supports_max_reasoning_effort"] is False + kept = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "max"}), + model="gpt-6-astra", + drop_params=True, + ) + assert kept["reasoning"] == {"effort": "max"} + + +@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_azure_responses_effort_gate_fires_when_azure_ai_prefix_survives_provider_remap( + local_model_cost_map: None, level: str +): + """litellm.responses(model="azure_ai/gpt-6.1-sol", api_base=) is re-routed to the + azure provider with the prefix intact, so this config sees the prefixed name. The gate used to + skip every prefixed name as another provider's pass-through, and a live Foundry gpt-6.1-sol + answered the forwarded none and minimal with 400 on 2026-10-02 even under drop_params. The + prefixed name reads the azure_ai/ row and drops or refuses exactly what that row turns off.""" + forwarded = ( + level == "max" + or litellm.model_cost["azure_ai/gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is not False + ) + dropped = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + max_output_tokens=60, reasoning={"effort": level} + ), + model="azure_ai/gpt-6.1-sol", + drop_params=True, + ) + assert ("reasoning" in dropped) is forwarded + if forwarded: + return + with pytest.raises(litellm.UnsupportedParamsError): + AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + max_output_tokens=60, reasoning={"effort": level} + ), + model="azure_ai/gpt-6.1-sol", + drop_params=False, + ) + + +def test_azure_responses_prefixed_name_reads_the_foundry_row_not_the_azure_twin( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + """The rows are made to disagree on low, a level neither real row flags, so the prefixed name + proving it reads azure_ai/gpt-6-sol cannot pass by reading azure/gpt-6-sol, which the bare + deployment name keeps reading.""" + monkeypatch.setitem( + litellm.model_cost, + "azure_ai/gpt-6-sol", + {**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False}, + ) + monkeypatch.setitem( + litellm.model_cost, + "azure/gpt-6-sol", + {**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True}, + ) + prefixed = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "low"}), + model="azure_ai/gpt-6-sol", + drop_params=True, + ) + assert "reasoning" not in prefixed + bare = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "low"}), + model="gpt-6-sol", + drop_params=True, + ) + assert bare["reasoning"] == {"effort": "low"} + + _COMPLETED_RESPONSE: Final = { "id": "resp_dated_chat_deployment", "object": "response", diff --git a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py index 634228da1bf..5186541a62b 100644 --- a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -306,17 +306,30 @@ def _local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url)) -def test_foundry_gpt_6_astra_keeps_sampling_params_when_reasoning_effort_is_none(_local_model_cost_map): +def test_foundry_gpt_6_sol_keeps_sampling_params_when_reasoning_effort_is_none(_local_model_cost_map): + """gpt-6-sol's Foundry row still says none, so the sampling params that ride on it stay. gpt-6-astra + used to be the model here until its row turned none off (a live Foundry deployment answered none + with a 400 naming low through xhigh on 2026-10-01); the test below pins that flip.""" optional_params = AzureAIStudioConfig().map_openai_params( non_default_params={"reasoning_effort": "none", "temperature": 0.2, "top_p": 0.9}, optional_params={}, - model="gpt-6-astra", + model="gpt-6-sol", drop_params=False, ) assert optional_params == {"reasoning_effort": "none", "temperature": 0.2, "top_p": 0.9} +def test_foundry_gpt_6_astra_refuses_reasoning_effort_none_before_the_call(_local_model_cost_map): + with pytest.raises(litellm.utils.UnsupportedParamsError): + AzureAIStudioConfig().map_openai_params( + non_default_params={"reasoning_effort": "none"}, + optional_params={}, + model="gpt-6-astra", + drop_params=False, + ) + + def test_azure_ai_grok_stop_parameter_handling(): diff --git a/tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py b/tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py index bae956eb061..26076f8bb34 100644 --- a/tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py +++ b/tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py @@ -1,12 +1,15 @@ import json +from typing import Final import httpx import pytest import respx import litellm +from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig from litellm.responses.main import _will_bridge_to_chat_completions +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams from litellm.types.router import GenericLiteLLMParams from litellm.utils import ProviderConfigManager @@ -309,3 +312,74 @@ async def test_aresponses_serverless_host_stays_on_chat_bridge(): assert chat_route.called assert not responses_route.called assert chat_route.calls.last.request.headers["Authorization"] == "Bearer fake-key" + + +def _responses_params(**fields: object) -> ResponsesAPIOptionalRequestParams: + return ResponsesAPIOptionalRequestParams(**fields) # pyright: ignore[reportArgumentType] # TypedDict kwargs from a test helper + + +def test_foundry_responses_effort_gate_reads_the_azure_ai_row( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + """A Foundry deployment and its Azure OpenAI namesake are different products, so the Foundry + Responses route answers from the azure_ai/ row the Foundry chat route already reads, never from + the azure/ twin. The rows are made to disagree on low, a level neither real row flags, so a + leak from the wrong row cannot hide behind rows that happen to agree.""" + monkeypatch.setitem( + litellm.model_cost, + "azure_ai/gpt-6-sol", + {**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False}, + ) + monkeypatch.setitem( + litellm.model_cost, + "azure/gpt-6-sol", + {**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True}, + ) + + dropped: Final = AzureAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=_responses_params(reasoning={"effort": "low"}), + model="gpt-6-sol", + drop_params=True, + ) + assert "reasoning" not in dropped + + with pytest.raises(litellm.UnsupportedParamsError): + AzureAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=_responses_params(reasoning={"effort": "low"}), + model="gpt-6-sol", + drop_params=False, + ) + + kept_by_azure_openai: Final = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=_responses_params(reasoning={"effort": "low"}), + model="gpt-6-sol", + drop_params=True, + ) + assert kept_by_azure_openai["reasoning"] == {"effort": "low"} + + +def test_foundry_responses_none_unlocks_temperature_off_the_azure_ai_row( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + """The sampling-param unlock that rides on none reads the same azure_ai/ row as the effort gate: + with the azure/ twin turned off for none, the Foundry row still lets none through and, only + then, a non-default temperature.""" + monkeypatch.setitem( + litellm.model_cost, + "azure_ai/gpt-6-sol", + {**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_none_reasoning_effort": True}, + ) + monkeypatch.setitem( + litellm.model_cost, + "azure/gpt-6-sol", + {**litellm.model_cost["azure/gpt-6-sol"], "supports_none_reasoning_effort": False}, + ) + + params: Final = AzureAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=_responses_params(temperature=0.2, reasoning={"effort": "none"}), + model="gpt-6-sol", + drop_params=False, + ) + + assert params["temperature"] == 0.2 + assert params["reasoning"] == {"effort": "none"} diff --git a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py index f85e4db447b..020f491dcfd 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py @@ -2303,6 +2303,115 @@ class TestResponsesSurfaceSharesTheEffortRule: ) assert ("top_p" in mapped) is top_p_survives + @pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + def test_effort_levels_the_map_row_turns_off_are_dropped_or_refused(self, local_model_cost_map, level): + """Same row as the chat gate, read opt-out for every level but max. gpt-6.1-sol's row turns + none and minimal off: OpenAI lists low, medium, high, xhigh and max for it + (https://developers.openai.com/api/docs/models/gpt-6.1-sol, via #43910), and a local proxy + got OpenAI's 400 for none and minimal through /v1/responses on 2026-09-30.""" + forwarded = ( + level == "max" or litellm.model_cost["gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is not False + ) + dropped = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-6.1-sol", + drop_params=True, + ) + assert ("reasoning" in dropped) is forwarded + if forwarded: + kept = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-6.1-sol", + drop_params=False, + ) + assert kept["reasoning"] == {"effort": level} + else: + with pytest.raises(litellm.UnsupportedParamsError): + OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-6.1-sol", + drop_params=False, + ) + + @pytest.mark.parametrize( + ("flags", "level", "forwarded"), + [ + ({}, "none", True), + ({"supports_none_reasoning_effort": False}, "none", False), + ({"supports_none_reasoning_effort": True}, "none", True), + ({"supports_minimal_reasoning_effort": False}, "minimal", False), + ({"supports_max_reasoning_effort": False}, "max", True), + ({}, "xhigh", True), + ({"supports_xhigh_reasoning_effort": False}, "xhigh", False), + ({"supports_none_reasoning_effort": False}, "medium", True), + ], + ) + def test_the_row_flags_alone_decide_whether_a_level_is_forwarded( + self, local_model_cost_map, monkeypatch, flags, level, forwarded + ): + """Same model name, rewritten row: this surface reads every level opt-out, xhigh included, + so the gate flips with the flags alone. max is the one level no flag can turn off here, + since the same model honors it on this route and refuses it on chat. The chat test module + pins the real row's facts.""" + real_row = litellm.model_cost["gpt-6.1-sol"] + without_flags = { + field: value + for field, value in real_row.items() + if not (field.startswith("supports_") and field.endswith("_reasoning_effort")) + } + monkeypatch.setitem(litellm.model_cost, "gpt-6.1-sol", {**without_flags, **flags}) + dropped = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-6.1-sol", + drop_params=True, + ) + assert ("reasoning" in dropped) is forwarded + if not forwarded: + with pytest.raises(litellm.UnsupportedParamsError): + OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-6.1-sol", + drop_params=False, + ) + + def test_dropping_a_refused_effort_keeps_the_rest_of_the_reasoning_object(self, local_model_cost_map): + refused = next( + level + for level in ("none", "minimal", "low") + if litellm.model_cost["gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is False + ) + mapped = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": refused, "summary": "auto"}}, + model="gpt-6.1-sol", + drop_params=True, + ) + assert mapped["reasoning"] == {"summary": "auto"} + + @pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + def test_gpt5_3_codex_forwards_exactly_the_effort_levels_its_map_row_allows(self, local_model_cost_map, level): + """The gate turns a stale false into a local refusal, so the Responses-only codex row has to + say what the route does. On 2026-10-01 /v1/responses answered gpt-5.3-codex none and xhigh + with 200 (effort echoed back) and minimal and max with 400 unsupported_value naming none, + low, medium, high and xhigh, so the row takes none and xhigh and turns minimal off.""" + forwarded = ( + level == "max" or litellm.model_cost["gpt-5.3-codex"].get(f"supports_{level}_reasoning_effort") is not False + ) + mapped = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": level}}, + model="gpt-5.3-codex", + drop_params=True, + ) + assert ("reasoning" in mapped) is forwarded + + def test_gpt5_3_codex_row_takes_none_and_xhigh(self, local_model_cost_map): + """Pinned on purpose so the row-derived test above cannot go vacuous: the live answers of + 2026-10-01 listed above. When OpenAI changes the set, flip the row and this assertion + together.""" + row = litellm.model_cost["gpt-5.3-codex"] + assert row["supports_none_reasoning_effort"] is True + assert row["supports_xhigh_reasoning_effort"] is True + assert row["supports_minimal_reasoning_effort"] is False + def test_top_p_raises_without_drop_params(self, local_model_cost_map): with pytest.raises(litellm.UnsupportedParamsError): OpenAIResponsesAPIConfig().map_openai_params( diff --git a/tests/unit/llms/openai/test_gpt5_transformation.py b/tests/unit/llms/openai/test_gpt5_transformation.py index 41f5816600f..7b10411ba20 100644 --- a/tests/unit/llms/openai/test_gpt5_transformation.py +++ b/tests/unit/llms/openai/test_gpt5_transformation.py @@ -1,4 +1,6 @@ import re +from collections.abc import Mapping +from typing import Final import pytest @@ -431,6 +433,105 @@ def test_gpt5_drops_reasoning_effort_minimal_when_requested(config: OpenAIConfig assert "reasoning_effort" not in params +def _chat_row_forwards(row: Mapping[str, object], level: str) -> bool: + """Chat polarity: max is never gated, xhigh needs an explicit true, every other level only has + to not be false.""" + if level == "max": + return True + flag: Final = row.get(f"supports_{level}_reasoning_effort") + return flag is True if level == "xhigh" else flag is not False + + +@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_gpt6_1_sol_forwards_exactly_the_effort_levels_its_map_row_allows(config: OpenAIConfig, level: str): + """The map row is the whole contract, so a new GPT-6 model is gated by its row with no code + change. gpt-6.1-sol's row turns none and minimal off: OpenAI lists low, medium, high, xhigh + and max for it (https://developers.openai.com/api/docs/models/gpt-6.1-sol, via #43910), and a + local proxy got OpenAI's 400 for none and minimal on both endpoints on 2026-09-30.""" + forwarded: Final = _chat_row_forwards(litellm.model_cost["gpt-6.1-sol"], level) + dropped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model="gpt-6.1-sol", + drop_params=True, + ) + assert ("reasoning_effort" in dropped) is forwarded + if forwarded: + kept: Final = config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model="gpt-6.1-sol", + drop_params=False, + ) + assert kept["reasoning_effort"] == level + else: + with pytest.raises(litellm.utils.UnsupportedParamsError): + config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model="gpt-6.1-sol", + drop_params=False, + ) + + +def test_gpt6_1_sol_row_turns_none_and_minimal_off(): + """Pinned on purpose so the row-derived test above cannot go vacuous: OpenAI lists low, medium, + high, xhigh and max for gpt-6.1-sol and neither none nor minimal + (https://developers.openai.com/api/docs/models/gpt-6.1-sol, read 2026-09-30 via #43910), and a + local proxy got OpenAI's 400 for both on 2026-09-30. When OpenAI adds a level, flip the row and + this assertion together.""" + row: Final = litellm.model_cost["gpt-6.1-sol"] + assert row["supports_none_reasoning_effort"] is False + assert row["supports_minimal_reasoning_effort"] is False + + +def _row_without_effort_flags(row: Mapping[str, object]) -> dict[str, object]: + return { + field: value + for field, value in row.items() + if not (field.startswith("supports_") and field.endswith("_reasoning_effort")) + } + + +@pytest.mark.parametrize( + ("flags", "level", "forwarded"), + [ + ({}, "none", True), + ({"supports_none_reasoning_effort": False}, "none", False), + ({"supports_none_reasoning_effort": True}, "none", True), + ({"supports_minimal_reasoning_effort": False}, "minimal", False), + ({"supports_max_reasoning_effort": False}, "max", True), + ({}, "max", True), + ({}, "xhigh", False), + ({"supports_xhigh_reasoning_effort": True}, "xhigh", True), + ({"supports_none_reasoning_effort": False}, "medium", True), + ], +) +def test_the_row_flags_alone_decide_whether_a_chat_level_is_forwarded( + config: OpenAIConfig, monkeypatch: pytest.MonkeyPatch, flags: dict[str, bool], level: str, forwarded: bool +): + """Same model name, rewritten row: the gate flips with the flags and nothing else, so breaking the + gate fails here while the pinned test above catches the real row losing a flag.""" + monkeypatch.setitem( + litellm.model_cost, "gpt-6.1-sol", {**_row_without_effort_flags(litellm.model_cost["gpt-6.1-sol"]), **flags} + ) + dropped: Final = config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model="gpt-6.1-sol", + drop_params=True, + ) + assert ("reasoning_effort" in dropped) is forwarded + if not forwarded: + with pytest.raises(litellm.utils.UnsupportedParamsError): + config.map_openai_params( + non_default_params={"reasoning_effort": level}, + optional_params={}, + model="gpt-6.1-sol", + drop_params=False, + ) + + def test_gpt5_minimal_dict_triggers_validation(config: OpenAIConfig): """Dict with effort='minimal' triggers minimal model-support validation.""" with pytest.raises(litellm.utils.UnsupportedParamsError): diff --git a/tests/unit/router_utils/test_reasoning_effort_capability.py b/tests/unit/router_utils/test_reasoning_effort_capability.py index 6a0c274f149..2226b1f39df 100644 --- a/tests/unit/router_utils/test_reasoning_effort_capability.py +++ b/tests/unit/router_utils/test_reasoning_effort_capability.py @@ -395,22 +395,25 @@ class TestGpt6AstraAdvertisesItsDocumentedLevels: [ ("azure/gpt-6-astra", "azure"), ("azure/us/gpt-6-astra", "azure"), + ("azure/eu/gpt-6-astra", "azure"), ("azure_ai/gpt-6-astra", "azure_ai"), ], ) - def test_an_azure_hosted_deployment_advertises_none_but_not_max( + def test_an_azure_hosted_deployment_advertises_the_levels_both_its_routes_accept( self, local_model_cost_map, model, custom_llm_provider ): - """Microsoft hosts the same model with a different level set than OpenAI does. Verified live - on both Azure routes: none returns 200 with zero reasoning tokens and unlocks temperature, - which OpenAI's API rejects, while max returns 400 unsupported_value naming none through - xhigh as the levels it does take.""" + """Verified live on 2026-10-01 against a Foundry gpt-6-astra deployment (model + gpt-6-astra-2026-09-03): none returns 400 unsupported_value on chat and on Responses, naming + low, medium, high and xhigh as the levels it takes; Responses also honors max with reasoning + tokens billed while chat refuses it, as OpenAI's chat does. The flag is route-blind, so the + row keeps max off and the advertised set stops at the level every route accepts. + Earlier evidence (2026-09-05, commit e79f3ec5205) had none accepted on chat; the deployment + no longer behaves that way.""" from litellm.utils import get_model_info_helper model_info = dict(get_model_info_helper(model=model, custom_llm_provider=custom_llm_provider)) assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == ( - "none", "low", "medium", "high", diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 7c292ea8f08..63f440d4aba 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1305,6 +1305,50 @@ def test_responses_api_bridge_check_gpt_5_4_tools_with_reasoning_none_stays_chat assert model_info.get("mode") != "responses" +@pytest.mark.parametrize( + "model, custom_llm_provider, reasoning_effort", + [ + ("gpt-6.1-sol", "openai", "none"), + ("gpt-6.1-sol", "openai", {"effort": "none"}), + ("gpt-6-astra", "azure", "none"), + ], +) +def test_responses_api_bridge_check_tools_with_a_none_the_row_refuses_routes_to_responses( + local_model_cost_map: None, model: str, custom_llm_provider: str, reasoning_effort: object +) -> None: + """The ``none`` escape hatch only holds when the chat gate forwards it. These rows refuse + ``none``, so the gate drops it (or refuses the call) and the provider runs its default effort, + under which Chat Completions rejects function tools; the bridge has to fire the way it does + for an unset effort. Row-derived: the test follows the flag, never the vendor's answer.""" + from litellm.main import responses_api_bridge_check + + row_key: Final = model if custom_llm_provider == "openai" else f"{custom_llm_provider}/{model}" + assert litellm.model_cost[row_key]["supports_none_reasoning_effort"] is False + + model_info, _ = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort=reasoning_effort, + ) + + assert model_info.get("mode") == "responses" + + +def test_responses_api_bridge_check_a_none_the_row_refuses_stays_chat_without_tools( + local_model_cost_map: None, +) -> None: + """Without function tools nothing forces the Responses route: the dropped ``none`` leaves a + plain chat call at the default effort, as for any other dropped level.""" + from litellm.main import responses_api_bridge_check + + model_info, _ = responses_api_bridge_check( + model="gpt-6.1-sol", custom_llm_provider="openai", reasoning_effort="none" + ) + + assert model_info.get("mode") != "responses" + + def test_responses_api_bridge_check_reasoning_none_with_summary_still_routes_to_responses(): """A reasoning summary is Responses-only regardless of effort value.""" from litellm.main import responses_api_bridge_check @@ -1712,11 +1756,15 @@ _FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_ pytest.param("gpt-6-astra", "https://myresource.openai.azure.com", None, id="gpt-6-azure-openai-host"), pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "low", id="gpt-5.6-explicit-effort"), pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, {"effort": "high"}, id="gpt-5.6-explicit-effort-dict"), + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="gpt-6-row-refused-none"), ], ) def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_responses( model_name, api_base, reasoning_effort ): + """The explicit ``none`` only keeps a Foundry gpt-6 request on chat while the deployment takes + it; the ``azure_ai/gpt-6-astra`` row says it does not, so the gate drops it and the call runs at + the default effort, under which Foundry rejects function tools from gpt-6 on.""" from litellm.main import responses_api_bridge_check model_info, model = responses_api_bridge_check( @@ -1734,7 +1782,6 @@ def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_res @pytest.mark.parametrize( "model_name, api_base, reasoning_effort", [ - pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"), pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, None, id="gpt-5.6-unset-effort-stays-chat"), pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "none", id="gpt-5.6-explicit-none-stays-chat"), pytest.param("gpt-5.5", _FOUNDRY_API_BASE, "high", id="gpt-5.5-explicit-effort-stays-chat"),