mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
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:
parent
40e4ade6a6
commit
d7620c77a1
18 changed files with 822 additions and 153 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue