fix(openai): drop unsupported reasoning effort for gpt-6.1-sol (#43935)

* fix(openai): drop unsupported reasoning effort for gpt-6.1-sol

gpt-6.1-sol accepts low, medium, high, xhigh, and max. none and minimal were still forwarded, because chat completions ignored an explicit supports_none_reasoning_effort=false and the Responses mapper never applied that contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(openai): mark the stripped reasoning object as outgoing JSON

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Azure responses reading the OpenAI effort map

gpt-6-astra on Azure accepts reasoning.effort none. The responses gate was checking the OpenAI entry, which rejects it.

Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(openai): gate every reasoning_effort level from the cost map on chat and Responses

One gate reads the model's cost-map row for whatever effort level a request
asks for, on Chat Completions and the Responses API, for OpenAI and Azure.
xhigh stays opt-in on chat (Azure also keeps none opt-in); every other level
is refused only when its supports_<level>_reasoning_effort flag is false. A
refused level is dropped under drop_params or answered with a local 400, and
Responses keeps the rest of the reasoning object when it drops effort. The
guards that follow judge temperature by the level the provider will see.

Tests move into the mapped test files and derive the expected outcome for
each level from the map row, so a new GPT-6 model needs only its row.

* test(openai): pin gpt-6.1-sol's refused effort levels and prove the row flags alone drive the gate

* fix(cost-map): azure gpt-6-astra refuses none and honors max on Responses, as live calls show

* test(reasoning_effort): pin the astra max and none flips through the adapter and Foundry paths

* fix(reasoning_effort): keep max out of the route gates and correct the gpt-5.3-codex rows

The same model honors max on Responses and refuses it on Chat Completions, so one row flag
cannot gate it on either route. The gates now skip max, the Azure gpt-6-astra rows keep max
off so the /v1/messages adapter degrades to xhigh and /model_group/info stops short of it as
on main, and chat still forwards max for the Responses bridge.

The gpt-5.3-codex rows said none and xhigh off and minimal on; the live model answers the
other way round (2026-10-01), and an opt-out gate would have dropped the levels Codex CLI sends.

* fix(reasoning_effort): bridge function tools to Responses when the chat gate drops none

* docs(reasoning_effort): correct the max advertisement note

* fix(azure_ai): read the Foundry row on the Responses effort gate and turn none off for Azure gpt-6.1-sol

* fix(azure): gate a prefixed Foundry deployment's Responses effort off its row

litellm.responses(model="azure_ai/<deployment>", api_base=<Foundry host>) is
re-routed to the azure provider with the azure_ai/ prefix intact, so the Azure
Responses config saw a prefixed name, skipped it as another provider's
pass-through, and forwarded none and minimal to Azure even under drop_params.
The Azure gpt-5 resolver also read no row at all for a prefixed name.

The Azure Responses gate now detects the family the way the Azure chat gate
does, and an azure_ai/ name on the Azure OpenAI configs reads its azure_ai/ row
when the map has one, else the azure/ twin, the rule the Foundry chat config
already applies to the same deployment.

* fix(cost-map): turn none off on azure/eu/gpt-6-astra like its sibling rows

* docs(reasoning_effort): say how each gate reads none and shorten the module docstring

* fix(azure_ai): call the public effort-level check from the Foundry Responses gate

* test(reasoning_effort): fold the Foundry Responses tests into the mapped file and type the row helpers

---------

Signed-off-by: Paul El Khoury <82139421+pOpO1-9@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
Paul El Khoury 2026-10-19 20:40:56 +02:00 • committed by GitHub
parent 40e4ade6a6
commit d7620c77a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 822 additions and 153 deletions

View file

@ -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/<model>`` 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,

View file

@ -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)

View file

@ -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(

View file

@ -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_<level>_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

View file

@ -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)

View file

@ -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,

View file

@ -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 <region>.privatelink.api.openai.com) and
# by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default

View file

@ -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

View file

@ -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,

View file

@ -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):

View file

@ -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

View file

@ -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=<Foundry host>) 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",

View file

@ -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():

View file

@ -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"}

View file

@ -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(

View file

@ -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):

View file

@ -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",

View file

@ -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"),