fix: use model capabilities for OpenAI sampling parameters

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-11-06 17:51:07 +00:00
parent ddd162439f
commit 95a0b7d7a6
11 changed files with 581 additions and 44 deletions

View file

@ -8,11 +8,12 @@ Translations handled by LiteLLM:
- Other parameters follow base Azure OpenAI Responses API behavior
"""
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Final
from litellm._logging import verbose_logger
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.utils import supports_reasoning
from litellm.utils import has_catalog_entry, supports_reasoning
from .transformation import AzureOpenAIResponsesAPIConfig
@ -39,7 +40,9 @@ class AzureOpenAIOSeriesResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
O-series models don't support temperature parameter in responses API.
"""
# Get the base Azure supported params
base_supported_params: Final = super().get_supported_openai_params(model)
base_supported_params: Final[Sequence[str]] = super().get_supported_openai_params(model)
if has_catalog_entry(model=model, custom_llm_provider="azure") and self._sampling_params_allowed(model, None):
return list(base_supported_params)
# O-series models don't support temperature parameter in responses API
o_series_unsupported_params: Final = ["temperature"]
@ -63,6 +66,13 @@ class AzureOpenAIOSeriesResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
Drops temperature parameter if drop_params is True since O-series models
don't support temperature in the responses API.
"""
if has_catalog_entry(model=model, custom_llm_provider="azure"):
return dict(
super().map_openai_params_for_deployment(
response_api_optional_params, model, drop_params, declared_reasoning_efforts=None
)
)
mapped_params: Final = dict(response_api_optional_params)
# If drop_params is enabled, remove temperature parameter for O-series models

View file

@ -97,6 +97,11 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.AZURE
def _sampling_params_allowed(self, model: str, effort: str | None) -> bool:
return AzureOpenAIGPT5Config.sampling_params_allowed(
model, effort, fallback_allowed=not self._is_gpt_5_model(model)
)
@staticmethod
def _is_gpt_5_model(model: str) -> bool:
return AzureOpenAIGPT5Config.is_model_gpt_5_model(model)

View file

@ -11,6 +11,7 @@ from litellm.utils import (
has_catalog_entry,
is_explicitly_disabled_factory,
supports_factory,
supports_sampling_params,
)
from .gpt_transformation import OpenAIGPTConfig
@ -205,6 +206,14 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
) -> bool:
return cls._supports_reasoning_effort_level(model, level)
@classmethod
def sampling_params_allowed(cls, model: str, effort: str | None, *, fallback_allowed: bool) -> bool:
lookup: Final = cls._model_map_lookup_name(model)
if cls._supports_reasoning_effort_level(model, "none") and cls.effort_resolves_to_none(model, effort):
return True
flag: Final = supports_sampling_params(lookup)
return fallback_allowed if flag is None else flag
@classmethod
def effort_resolves_to_none(cls, model: str, effective_effort: str | None) -> bool:
"""Whether this request's reasoning effort ends up as "none", which is the single
@ -318,7 +327,9 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
# gpt-5.1/5.2 support logprobs, top_p, top_logprobs when reasoning_effort="none"
if not self._supports_reasoning_effort_level(model, "none"):
non_supported_params.extend(["logprobs", "top_p", "top_logprobs"])
non_supported_params.extend(["logprobs", "top_logprobs"])
if not self.sampling_params_allowed(model, None, fallback_allowed=False):
non_supported_params.append("top_p")
return [param for param in base_gpt_series_params if param not in non_supported_params]
@ -398,7 +409,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
# gpt-5.1/5.2 support logprobs, top_p, top_logprobs only when reasoning_effort="none"
supports_none: Final = self._supports_reasoning_effort_level(model, "none")
if supports_none:
sampling_params: Final = ["logprobs", "top_logprobs", "top_p"]
sampling_params: Final = ["logprobs", "top_logprobs"]
has_sampling: Final = any(p in non_default_params for p in sampling_params)
if has_sampling and not self.effort_resolves_to_none(model, effective_effort):
if litellm.drop_params or drop_params:
@ -415,12 +426,19 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
status_code=400,
)
sampling_allowed: Final = self.sampling_params_allowed(model, effective_effort, fallback_allowed=False)
if "top_p" in non_default_params and not sampling_allowed:
if litellm.drop_params or drop_params:
non_default_params.pop("top_p", None)
else:
raise litellm.utils.UnsupportedParamsError(
message=f"{model} only supports top_p when reasoning_effort resolves to 'none'",
status_code=400,
)
if "temperature" in non_default_params:
temperature_value: Final[float | None] = non_default_params.pop("temperature")
if temperature_value is not None:
# a non-default temperature rides on the effort resolving to "none", not on
# the model merely supporting it
if (supports_none and self.effort_resolves_to_none(model, effective_effort)) or temperature_value == 1:
if sampling_allowed or temperature_value == 1:
optional_params["temperature"] = temperature_value
elif litellm.drop_params or drop_params:
pass

View file

@ -25,6 +25,7 @@ from litellm.utils import (
supports_system_messages,
)
from .gpt_reasoning_transformation import OpenAIGPT5Config
from .gpt_transformation import OpenAIGPTConfig
@ -60,7 +61,6 @@ class OpenAIOSeriesConfig(OpenAIGPTConfig):
all_openai_params: Final = super().get_supported_openai_params(model=model)
non_supported_params: Final = [
"logprobs",
"top_p",
"presence_penalty",
"frequency_penalty",
"top_logprobs",
@ -68,6 +68,9 @@ class OpenAIOSeriesConfig(OpenAIGPTConfig):
o_series_only_param: Final = ["reasoning_effort"]
if not OpenAIGPT5Config.sampling_params_allowed(model, None, fallback_allowed=False):
non_supported_params.append("top_p")
all_openai_params.extend(o_series_only_param)
try:
@ -105,10 +108,22 @@ class OpenAIOSeriesConfig(OpenAIGPTConfig):
):
if "max_tokens" in non_default_params:
optional_params["max_completion_tokens"] = non_default_params.pop("max_tokens")
sampling_params: Final = cast( # cast-ok: legacy mapper accepts an untyped parameter bag
"dict[str, object]", non_default_params
)
reasoning_effort: Final = sampling_params.get("reasoning_effort")
sampling_allowed: Final = OpenAIGPT5Config.sampling_params_allowed(
model, reasoning_effort if isinstance(reasoning_effort, str) else None, fallback_allowed=False
)
if "top_p" in non_default_params and not sampling_allowed:
if litellm.drop_params or drop_params:
sampling_params.pop("top_p", None)
else:
raise litellm.utils.UnsupportedParamsError(message=f"{model} doesn't support top_p", status_code=400)
if "temperature" in non_default_params:
temperature_value: Final[float | None] = non_default_params.pop("temperature")
if temperature_value is not None:
if temperature_value == 1:
if sampling_allowed or temperature_value == 1:
optional_params["temperature"] = temperature_value
else:
## UNSUPPORTED TOOL CHOICE VALUE

View file

@ -35,6 +35,7 @@ from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.openai.chat.gpt_reasoning_transformation import (
OpenAIGPT5Config,
is_gpt_reasoning_series_name,
refused_reasoning_effort_warning,
unsupported_reasoning_effort_message,
@ -373,6 +374,13 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
def _model_map_lookup_name(model: str) -> str:
return model
def _sampling_params_allowed(self, model: str, effort: str | None) -> bool:
if self.custom_llm_provider not in (LlmProviders.OPENAI, LlmProviders.AZURE):
return not self._is_gpt_5_model(model) or (
self._supports_reasoning_effort_none(model) and self._effort_resolves_to_none(model, effort)
)
return OpenAIGPT5Config.sampling_params_allowed(model, effort, fallback_allowed=not self._is_gpt_5_model(model))
@staticmethod
def _supports_reasoning_effort_none(model: str) -> bool:
"""Return True if the model supports reasoning.effort='none'."""
@ -494,7 +502,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
) -> Mapping[str, object]:
"""No mapping applied since inputs are in OpenAI spec already.
GPT-5 models have restrictions on temperature and top_p (only temperature=1
Reasoning models have restrictions on temperature and top_p (only temperature=1
is accepted, and top_p is rejected, unless reasoning.effort resolves to
'none' on models that support it).
Apply the same validation used by the chat completions path.
@ -553,42 +561,43 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
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)
forwarded_reasoning: Final = params.get("reasoning")
effort: Final = self._requested_effort(forwarded_reasoning)
sampling_allowed: Final = self._sampling_params_allowed(
lookup_name, effort if isinstance(effort, str) else None
)
temperature: Final = params.get("temperature")
if temperature is not None and temperature != 1:
if effort_is_none:
pass # flexible temperature allowed
elif drop_params or litellm.drop_params:
params.pop("temperature", None)
else:
raise litellm.UnsupportedParamsError(
message=(
f"{model} doesn't support temperature={temperature} while reasoning is "
"active. Only temperature=1 is supported unless reasoning.effort resolves "
"to 'none', either set explicitly on the request or declared as the "
"model's default_reasoning_effort. "
"To drop unsupported params set `litellm.drop_params = True`"
),
status_code=400,
)
temperature: Final = params.get("temperature")
if temperature is not None and temperature != 1:
if sampling_allowed:
pass
elif drop_params or litellm.drop_params:
params.pop("temperature", None)
else:
raise litellm.UnsupportedParamsError(
message=(
f"{model} doesn't support temperature={temperature} while reasoning is "
"active. Only temperature=1 is supported unless reasoning.effort resolves "
"to 'none', either set explicitly on the request or declared as the "
"model's default_reasoning_effort. "
"To drop unsupported params set `litellm.drop_params = True`"
),
status_code=400,
)
if "top_p" in params and not effort_is_none:
if drop_params or litellm.drop_params:
params.pop("top_p", None)
else:
raise litellm.UnsupportedParamsError(
message=(
f"{model} only supports top_p when reasoning.effort resolves to 'none', "
"either set explicitly on the request or declared as the model's "
"default_reasoning_effort. "
"To drop unsupported params set `litellm.drop_params = True`"
),
status_code=400,
)
if "top_p" in params and not sampling_allowed:
if drop_params or litellm.drop_params:
params.pop("top_p", None)
else:
raise litellm.UnsupportedParamsError(
message=(
f"{model} only supports top_p when reasoning.effort resolves to 'none', "
"either set explicitly on the request or declared as the model's "
"default_reasoning_effort. "
"To drop unsupported params set `litellm.drop_params = True`"
),
status_code=400,
)
return params

File diff suppressed because it is too large Load diff

View file

@ -3562,6 +3562,11 @@ def supports_audio_input(model: str, custom_llm_provider: str | None = None) ->
return supports_factory(model=model, custom_llm_provider=custom_llm_provider, key="supports_audio_input")
def supports_sampling_params(model: str, custom_llm_provider: str | None = None) -> bool | None:
info: Final = lookup_catalog_model_info(model, custom_llm_provider)
return None if isinstance(info, ModelInfoLookupMiss) else info.get("supports_sampling_params")
def supports_video_input(model: str, custom_llm_provider: str | None = None) -> bool:
return supports_factory(model=model, custom_llm_provider=custom_llm_provider, key="supports_video_input")
@ -7580,6 +7585,7 @@ def get_model_info_helper(
supports_prompt_caching=_model_info.get("supports_prompt_caching", None),
supports_prompt_cache_breakpoint=_model_info.get("supports_prompt_cache_breakpoint", None),
supports_thinking_cache_preservation=_model_info.get("supports_thinking_cache_preservation", None),
supports_sampling_params=_model_info.get("supports_sampling_params", None),
supports_audio_input=_model_info.get("supports_audio_input", None),
supports_video_input=_model_info.get("supports_video_input", None),
supports_box_questions=_model_info.get("supports_box_questions", None),

File diff suppressed because it is too large Load diff

View file

@ -1814,3 +1814,12 @@ def test_azure_reasoning_replay_drops_status_always_and_content_under_drop_param
assert request["input"] == [replayable if drop_params else {**replayable, "content": saved_content}]
assert history == original
@pytest.mark.parametrize("model", ["o3", "azure/o3", "azure/us/o3"])
@pytest.mark.parametrize("param,value", [("temperature", 0.5), ("top_p", 0.9)])
def test_azure_o_series_responses_sampling_cost_map(model: str, param: str, value: float) -> None:
config: Final = AzureOpenAIOSeriesResponsesAPIConfig()
assert config.map_openai_params({param: value}, model, drop_params=True) == {}
with pytest.raises(litellm.UnsupportedParamsError):
config.map_openai_params({param: value}, model, drop_params=False)

View file

@ -13,6 +13,7 @@ from openai.types.responses.response_compaction_item import ResponseCompactionIt
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig
@ -5052,3 +5053,40 @@ def test_compact_request_keeps_stream_under_drop_params(config: OpenAIResponsesA
)
assert {key: data[key] for key in data if key != "input"} == {"model": "gpt-5.4-mini", "stream": True}
@pytest.mark.parametrize("param,value", [("temperature", 0.5), ("top_p", 0.9)])
@pytest.mark.parametrize("model", ["o3", "openai/o3", "o4-mini", "o1-pro"])
def test_o_series_responses_rejects_or_drops_sampling(model: str, param: str, value: float) -> None:
config: Final = OpenAIResponsesAPIConfig()
assert config.map_openai_params({param: value}, model, drop_params=True) == {}
with pytest.raises(litellm.UnsupportedParamsError):
config.map_openai_params({param: value}, model, drop_params=False)
@pytest.mark.parametrize(
"model,reasoning,expected",
[
("gpt-5.1", {"effort": "none"}, {"temperature": 0.5, "top_p": 0.9, "reasoning": {"effort": "none"}}),
("gpt-5.5", {"effort": "none"}, {"temperature": 0.5, "top_p": 0.9, "reasoning": {"effort": "none"}}),
("gpt-5.5", {"effort": "medium"}, {"reasoning": {"effort": "medium"}}),
("gpt-5-chat-latest", None, {"temperature": 0.5, "top_p": 0.9}),
("openai/custom-sampling-deployment", None, {"temperature": 0.5, "top_p": 0.9}),
("openai/gpt-5-custom-sampling-deployment", None, {}),
],
)
def test_responses_sampling_effort_and_unknown_fallback(
model: str, reasoning: dict[str, str] | None, expected: dict[str, object]
) -> None:
params: Final = {"temperature": 0.5, "top_p": 0.9, **({"reasoning": reasoning} if reasoning else {})}
assert OpenAIResponsesAPIConfig().map_openai_params(params, model, drop_params=True) == expected
def test_responses_empty_sampling_params() -> None:
assert OpenAIResponsesAPIConfig().map_openai_params({}, "o3", drop_params=False) == {}
@pytest.mark.parametrize("drop_params", [True, False])
def test_hosted_vllm_responses_keeps_sampling_when_effort_is_none(drop_params: bool) -> None:
params: Final = {"temperature": 0.5, "top_p": 0.9, "reasoning": {"effort": "none"}}
assert HostedVLLMResponsesAPIConfig().map_openai_params(params, "gpt-5.1", drop_params) == params

View file

@ -10,6 +10,21 @@ from litellm.llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig
import os
@pytest.mark.parametrize("param,value", [("temperature", 0.5), ("top_p", 0.9)])
def test_o_series_chat_sampling_cost_map_override(param: str, value: float) -> None:
model: Final = "openai/o3-sampling-enabled-test"
with patch.dict(litellm.model_cost, {model: {"litellm_provider": "openai", "mode": "chat", "supports_sampling_params": True}}):
assert OpenAIOSeriesConfig().map_openai_params({param: value}, {}, model, False) == {param: value}
@pytest.mark.parametrize("param,value", [("temperature", 0.5), ("top_p", 0.9)])
def test_o_series_chat_sampling_unknown_fallback(param: str, value: float) -> None:
config: Final = OpenAIOSeriesConfig()
assert config.map_openai_params({param: value}, {}, "o3-unknown-test", True) == {}
with pytest.raises(litellm.UnsupportedParamsError):
config.map_openai_params({param: value}, {}, "o3-unknown-test", False)
@pytest.mark.parametrize(
"model_name,expected",
[