fix(router): abstain on unknown reasoning efforts instead of guessing

A reasoning model whose map entry names no effort flag now resolves to None, so
the API omits the field and the dashboard keeps its six-level fallback, and a
deployment counts as catalog-known only when the map supplied its mode, so an
operator writing model_info on an off-map deployment no longer empties the
levels its mapped siblings agree on.

Also drops the ultra level nothing asked for, forwards every level the public
literal names across the chat to Responses bridge, and removes the unreachable
supported_reasoning_efforts validator.
This commit is contained in:
mateo-berri 2026-08-22 17:41:54 -07:00 committed by Tin Chi Lo
parent 5b5f0004ba
commit 67f3cf0f0f
19 changed files with 421 additions and 201 deletions

View file

@ -5,7 +5,7 @@ Handler for transforming /chat/completions api requests to litellm.responses req
import json
import os
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast, get_args
from openai.types.responses.custom_tool_param import CustomToolParam
from openai.types.responses.response_input_param import (
@ -35,6 +35,7 @@ from litellm.responses.sse_output_recovery import (
)
from litellm.responses.utils import normalize_responses_api_stream_options
from litellm.types.llms.openai import (
REASONING_EFFORT,
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
ChatCompletionToolCallChunk,
@ -1113,10 +1114,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
# Level-agnostic: providers own effort validation, so an unknown level (max, ultra, future
# ones) passes through instead of being silently dropped here. "default" means no override,
# so it maps to nothing rather than reaching a provider as a literal effort value.
if reasoning_effort and reasoning_effort != "default":
if reasoning_effort in get_args(REASONING_EFFORT):
return (
Reasoning(effort=reasoning_effort, summary="detailed")
if auto_summary_enabled

View file

@ -16,7 +16,7 @@ def _normalize_reasoning_effort_for_chat_completion(
) -> str | None:
"""Convert reasoning_effort to the string format expected by OpenAI chat completion API.
The chat completion API expects a simple effort string ('none' through 'ultra').
The chat completion API expects an effort string such as 'low' or 'high'.
Config/deployments may pass the Responses API format: {'effort': 'high', 'summary': 'detailed'}.
"""
if value is None:

View file

@ -416,8 +416,7 @@ async def acompletion(
logprobs: bool | None = None,
top_logprobs: int | None = None,
deployment_id=None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default"]
| None = None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"] | None = None,
verbosity: Literal["low", "medium", "high"] | None = None,
safety_identifier: str | None = None,
service_tier: str | None = None,
@ -4921,8 +4920,7 @@ def completion(
logit_bias: dict | None = None,
user: str | None = None,
# openai v1.0+ new params
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default"]
| None = None,
reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"] | None = None,
verbosity: Literal["low", "medium", "high"] | None = None,
response_format: dict | type[BaseModel] | None = None,
seed: int | None = None,

View file

@ -168,6 +168,7 @@ from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import
PromptCachingDeploymentCheck,
)
from litellm.router_utils.reasoning_effort_capability import (
deployment_is_catalog_mapped,
intersect_supported_reasoning_efforts,
resolve_supported_reasoning_efforts,
)
@ -9443,6 +9444,8 @@ class Router:
except Exception:
model_info = None
deployment_is_mapped = deployment_is_catalog_mapped(model_info, model_info_dict)
# get llm provider
litellm_model, llm_provider = "", ""
try:
@ -9565,7 +9568,7 @@ class Router:
model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
model_group_info.supported_reasoning_efforts,
resolve_supported_reasoning_efforts(model_info),
resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=deployment_is_mapped),
)
if _deployment_tpm is not None:

View file

@ -1,91 +1,131 @@
"""Resolve which reasoning_effort values a deployment, and by intersection a model group, accepts.
The model-map flags carry different polarity per level, mirroring the provider gates
(gpt_5_transformation.py restricts xhigh to explicit opt-in and treats minimal/low as opt-out):
medium and high are unconditional for any reasoning model, minimal/low are supported unless the map
explicitly says false, and xhigh/max require an explicit true. Shipping the resolved list keeps that
polarity in one place instead of re-encoding it in every consumer.
The model map's supports_*_reasoning_effort flags are the only signal, and each level's 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.
Only openai and azure gate xhigh and max on the request path. anthropic/chat/transformation.py
gates them on the output_config path alone, so its reasoning_effort path maps every level to a
thinking budget whatever the map says, and a claude group whose entry omits
supports_xhigh_reasoning_effort stops offering a level litellm would have forwarded. That is the
deliberate trade: an explicit flag is the only signal that the tier is a real one rather than
litellm quietly rounding the level to a budget, and what gets dropped is advisory metadata rather
than a restriction on the request path.
xhigh is gated on the request path by the openai and azure gpt-5 configs. max is not gated there at
all: 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 none level is the one flag whose polarity is provider-dependent. OpenAI never refuses it on the
request path (azure/chat/gpt_5_transformation.py is the only caller that does, and it raises
UnsupportedParamsError unless supports_none_reasoning_effort is explicitly true), so none is opt-out
everywhere except azure, where it is opt-in. Resolving it the other way for azure would advertise a
level litellm itself rejects, which is the failure this module exists to prevent.
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
bedrock nova entries among them take neither none nor minimal, so composing a set out of the
defaults alone would advertise levels those providers reject.
The advertisement order is the REASONING_EFFORT declaration order, which is presentation only. It
is not a strength scale and does not reconcile with bedrock's output_config ceiling order in
llms/bedrock/common_utils.py, which ranks max below xhigh while the thinking-budget constants rank
it above.
"""
from collections.abc import Mapping, Sequence
from typing import Final
from typing import Final, get_args
REASONING_EFFORT_CAPABILITY_ORDER: Final = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
import litellm
from litellm.types.llms.openai import REASONING_EFFORT
_OPT_OUT_FLAGS: Final = (
REASONING_EFFORT_ADVERTISEMENT_ORDER: Final = get_args(REASONING_EFFORT)
_EFFORT_FLAGS: Final = (
("none", "supports_none_reasoning_effort"),
("minimal", "supports_minimal_reasoning_effort"),
("low", "supports_low_reasoning_effort"),
)
_OPT_IN_FLAGS: Final = (
("xhigh", "supports_xhigh_reasoning_effort"),
("max", "supports_max_reasoning_effort"),
("ultra", "supports_ultra_reasoning_effort"),
)
_OPT_OUT_EFFORTS: Final = ("minimal", "low")
_OPT_IN_EFFORTS: Final = ("xhigh", "max")
_UNCONDITIONAL_EFFORTS: Final = frozenset(("medium", "high"))
_NONE_FLAG: Final = "supports_none_reasoning_effort"
_NONE_OPT_IN_PROVIDERS: Final = frozenset(("azure",))
def _supports_none_reasoning_effort(model_info: Mapping[str, object]) -> bool:
"""Opt-in on azure, whose gpt-5 config raises on reasoning_effort='none' without an explicit
true; opt-out elsewhere, where no request path refuses the level. A missing azure flag defers to
_supports_factory, the same resolver the azure gate calls, so its bare-model-name fallback
(azure/gpt-5.2 inheriting the flag from gpt-5.2) reaches both sides alike."""
flag: Final = model_info.get(_NONE_FLAG)
if model_info.get("litellm_provider") not in _NONE_OPT_IN_PROVIDERS:
def _bare_model_entry(model_info: Mapping[str, object]) -> Mapping[str, object]:
"""The unprefixed twin of a provider-prefixed map entry, which is where the flags often live:
azure/gpt-5-mini carries none of them while gpt-5-mini carries all three. The request-path
gates resolve through the same twin (_supports_factory, #20885), so reading it here is what
keeps the advertisement and the gate on the same answer."""
key: Final = model_info.get("key")
provider: Final = model_info.get("litellm_provider")
if not isinstance(key, str) or not isinstance(provider, str) or not key.startswith(f"{provider}/"):
return {}
entry: Final[Mapping[str, object] | None] = litellm.model_cost.get(key.removeprefix(f"{provider}/"))
return entry if entry is not None else {}
def _declared_effort_flags(model_info: Mapping[str, object]) -> Mapping[str, object]:
bare: Final = _bare_model_entry(model_info)
return {
effort: model_info.get(flag) if model_info.get(flag) is not None else bare.get(flag)
for effort, flag in _EFFORT_FLAGS
}
def _supports_none_reasoning_effort(model_info: Mapping[str, object], flag: object) -> bool:
"""Opt-in only where a request path refuses the level. AzureOpenAIGPT5Config raises
UnsupportedParamsError on reasoning_effort='none' without an explicit true, and it is selected
only for the gpt-5 family, so every other azure deployment keeps the opt-out default."""
if model_info.get("litellm_provider") != "azure":
return flag is not False
if flag is not None:
return flag is True
model_key: Final = model_info.get("key")
if not isinstance(model_key, str):
from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config
key: Final = model_info.get("key")
if not isinstance(key, str) or not AzureOpenAIGPT5Config.is_model_gpt_5_model(key):
return flag is not False
return flag is True
def deployment_is_catalog_mapped(
resolved_model_info: Mapping[str, object] | None,
operator_model_info: Mapping[str, object],
) -> bool:
"""Whether the model map described this deployment, as opposed to the operator describing it.
Every deployment is registered in the cost map under its own id, so a mode the operator wrote
on an off-map deployment reads back here exactly like one the catalog supplied. Excluding it is
what stops such a deployment from claiming to be a known non-reasoning model and emptying the
levels its mapped siblings agree on.
"""
if resolved_model_info is None or resolved_model_info.get("mode") is None:
return False
from litellm.utils import (
_supports_factory, # pyright: ignore[reportPrivateUsage] # the resolver the azure gate itself calls; a public wrapper would fork the fallback
)
return _supports_factory(model=model_key, custom_llm_provider=None, key=_NONE_FLAG)
return operator_model_info.get("mode") is None
def resolve_supported_reasoning_efforts(model_info: Mapping[str, object]) -> tuple[str, ...] | None:
"""None = nothing is known about this deployment, so it must not narrow its group; () = this is a
known model that accepts no effort level, which correctly empties the group. Keeping the two
apart matters because the router registers a deployment absent from the model map under a
synthesized entry, and get_model_info then answers with supports_reasoning None exactly as it
does for a mapped non-reasoning model. Mode is what separates them: every map entry for a
routable model declares one, so an unset flag with no mode is read as unknown and one custom
model in a group no longer wipes the levels its mapped siblings agree on.
def resolve_supported_reasoning_efforts(
model_info: Mapping[str, object],
*,
deployment_is_mapped: bool,
) -> tuple[str, ...] | None:
"""None = nothing is known about this deployment, so it must not narrow its group; () = a known
model that accepts no effort level, which correctly empties the group.
The separation is only as good as that signal. An off-map deployment carrying any model_info of
its own is registered under its deployment id with mode defaulting to chat, so it reads as a
known non-reasoning model and does still empty its group, with supports_reasoning on that
deployment as the way out. Telling the two apart for real needs provenance that the flattened
ModelInfo does not carry."""
if "supports_reasoning" not in model_info:
return None
Telling those apart needs provenance the flattened ModelInfo does not carry. A deployment the
map does not describe arrives with supports_reasoning None, exactly like a mapped non-reasoning
model: 2273 of the map's 3165 entries omit the key rather than setting it false, so reading an
unset flag as () would let one custom deployment empty every level its mapped siblings agree
on. deployment_is_mapped is that provenance, and an operator who wants either answer for an
off-map deployment gets it by setting supports_reasoning explicitly.
"""
supports_reasoning: Final = model_info.get("supports_reasoning")
if supports_reasoning is None and model_info.get("mode") is None:
return None
if supports_reasoning is not True:
return ()
opt_out: Final = frozenset(effort for effort, flag in _OPT_OUT_FLAGS if model_info.get(flag) is not False)
opt_in: Final = frozenset(effort for effort, flag in _OPT_IN_FLAGS if model_info.get(flag) is True)
none_level: Final = frozenset(("none",)) if _supports_none_reasoning_effort(model_info) else frozenset()
return () if supports_reasoning is False or deployment_is_mapped else None
flags: Final = _declared_effort_flags(model_info)
if all(value is None for value in flags.values()):
return None
opt_out: Final = frozenset(effort for effort in _OPT_OUT_EFFORTS if flags[effort] is not False)
opt_in: Final = frozenset(effort for effort in _OPT_IN_EFFORTS if flags[effort] is True)
none_level: Final = (
frozenset(("none",)) if _supports_none_reasoning_effort(model_info, flags["none"]) else frozenset()
)
allowed: Final = opt_out | _UNCONDITIONAL_EFFORTS | opt_in | none_level
return tuple(effort for effort in REASONING_EFFORT_CAPABILITY_ORDER if effort in allowed)
return tuple(effort for effort in REASONING_EFFORT_ADVERTISEMENT_ORDER if effort in allowed)
def intersect_supported_reasoning_efforts(
@ -99,4 +139,4 @@ def intersect_supported_reasoning_efforts(
if current is None:
return tuple(resolved)
keep: Final = frozenset(current) & frozenset(resolved)
return tuple(effort for effort in REASONING_EFFORT_CAPABILITY_ORDER if effort in keep)
return tuple(effort for effort in REASONING_EFFORT_ADVERTISEMENT_ORDER if effort in keep)

View file

@ -1840,7 +1840,7 @@ ResponsesAPIStreamingResponse = Annotated[
]
REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
class OpenAIRealtimeStreamSession(TypedDict, total=False):

View file

@ -641,19 +641,6 @@ class ModelGroupInfo(BaseModel):
supported_openai_params: list[str] | None = Field(default=[])
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
@field_validator("supported_reasoning_efforts", mode="before")
@classmethod
def _accept_only_well_formed_reasoning_efforts(cls, value: object) -> tuple[str, ...] | None:
"""Deployment model_info reaches this model through a **kwargs splat, so an operator can put
any shape under this key. Anything that is not a level or a list of levels resolves to None
and lets the computed intersection stand, because raising here fails the whole
/model_group/info response rather than the one group that carries the bad value."""
if isinstance(value, str):
return (value,)
if isinstance(value, (list, tuple)) and all(isinstance(level, str) for level in value):
return tuple(value)
return None
def __init__(self, **data) -> None:
for field_name, field_type in get_type_hints(self.__class__).items():
if field_type is bool and data.get(field_name) is None:

View file

@ -164,7 +164,6 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
supports_low_reasoning_effort: bool | None
supports_xhigh_reasoning_effort: bool | None
supports_max_reasoning_effort: bool | None
supports_ultra_reasoning_effort: bool | None # writable-ok: Pydantic warns on ReadOnly TypedDict fields
supports_output_config: bool | None
supports_image_size: bool | None
bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None

View file

@ -5764,7 +5764,6 @@ def _get_model_info_helper(
supports_low_reasoning_effort=_model_info.get("supports_low_reasoning_effort", None),
supports_xhigh_reasoning_effort=_model_info.get("supports_xhigh_reasoning_effort", None),
supports_max_reasoning_effort=_model_info.get("supports_max_reasoning_effort", None),
supports_ultra_reasoning_effort=_model_info.get("supports_ultra_reasoning_effort", None),
bedrock_output_config_effort_ceiling=_model_info.get("bedrock_output_config_effort_ceiling", None),
bedrock_converse_supports_strict_tools=_model_info.get("bedrock_converse_supports_strict_tools", None),
supports_computer_use=_model_info.get("supports_computer_use", None),

View file

@ -1585,16 +1585,16 @@ def test_map_reasoning_effort_adds_summary_detailed(monkeypatch):
assert result_dict["summary"] == "custom_summary"
print("✓ Dict input is passed through without modification")
# Test 5: levels this bridge does not enumerate (max, ultra, future ones) pass through so the
# provider can judge them, instead of being silently dropped before the request is built
# Test 5: every REASONING_EFFORT level reaches the provider, and anything else (a typo, an
# unshipped level, "default") is dropped so the request still succeeds at the provider default
from litellm.types.llms.openai import Reasoning
for effort in ("max", "ultra", "unknown_value"):
for effort in ("max", "xhigh", "none"):
result_passthrough = handler._map_reasoning_effort(effort)
assert result_passthrough == Reasoning(effort=effort)
assert handler._map_reasoning_effort("") is None
assert handler._map_reasoning_effort("default") is None
print("Unenumerated reasoning_effort levels pass through to the provider")
for dropped in ("ultra", "hgih", "unknown_value", "", "default"):
assert handler._map_reasoning_effort(dropped) is None
print("Enumerated levels pass through and unknown ones are dropped")
print(
"✓ All reasoning_effort behaviors work correctly with flag/env var control"

View file

@ -1334,7 +1334,7 @@ def test_gpt5_6_never_advertises_reasoning_effort_max(model: str):
gpt-5.6 entry asserts supports_max_reasoning_effort and the advertised set stops at xhigh."""
from litellm.router_utils.reasoning_effort_capability import resolve_supported_reasoning_efforts
resolved = resolve_supported_reasoning_efforts(litellm.get_model_info(model))
resolved = resolve_supported_reasoning_efforts(litellm.get_model_info(model), deployment_is_mapped=True)
assert resolved is not None
assert "max" not in resolved
assert "xhigh" in resolved
@ -1355,28 +1355,16 @@ def test_gpt5_6_keeps_reasoning_effort_max_on_the_responses_api(
assert params["reasoning"] == {"effort": "max"}
def test_gpt5_6_never_advertises_reasoning_effort_ultra():
"""ultra is plumbed as an opt-in level but no map entry asserts it: OpenAI's model guidance
documents effort values only up to max, and /v1/responses answers ultra with a 400. A verified
supports_ultra_reasoning_effort flag lights it up with no code change."""
from litellm.router_utils.reasoning_effort_capability import resolve_supported_reasoning_efforts
resolved = resolve_supported_reasoning_efforts(litellm.get_model_info("gpt-5.6"))
assert resolved is not None
assert "ultra" not in resolved
@pytest.mark.parametrize("effort", ["max", "ultra"])
def test_gpt5_forwards_levels_the_chat_gate_does_not_own(config: OpenAIConfig, effort: str):
"""Only xhigh is gated on this surface. max and ultra reach the provider (or the responses
bridge) and are answered there, which is what happened before per-group capabilities existed."""
def test_gpt5_forwards_levels_the_chat_gate_does_not_own(config: OpenAIConfig):
"""Only xhigh is gated on this surface. max reaches the provider (or the responses bridge) and
is answered there, which is what happened before per-group capabilities existed."""
params = config.map_openai_params(
non_default_params={"reasoning_effort": effort},
non_default_params={"reasoning_effort": "max"},
optional_params={},
model="gpt-5.1",
drop_params=False,
)
assert params["reasoning_effort"] == effort
assert params["reasoning_effort"] == "max"
def test_gpt5_rejects_xhigh_for_models_without_the_flag(config: OpenAIConfig):

View file

@ -1352,7 +1352,7 @@ class TestParseCursorModelVariant:
("gemini-3.0-pro-thinking-low", "gemini-3.0-pro", "low"),
("claude-opus-5-fast", "claude-opus-5", None),
("gpt-5.6-sol", "gpt-5.6-sol", None),
("gpt-5.6-thinking-ultra-fast", "gpt-5.6", "ultra"),
("foo-thinking-ultra-fast", "foo-thinking-ultra", None),
("gpt-5.6-thinking-max", "gpt-5.6", "max"),
("foo-thinking-mega-fast", "foo-thinking-mega", None),
("-thinking-high", "-thinking-high", None),

View file

@ -1,38 +1,51 @@
import pytest
from litellm.router_utils.reasoning_effort_capability import (
deployment_is_catalog_mapped,
intersect_supported_reasoning_efforts,
resolve_supported_reasoning_efforts,
)
class TestDeploymentIsCatalogMapped:
def test_a_mode_the_catalog_supplied_marks_the_deployment_mapped(self):
assert deployment_is_catalog_mapped({"mode": "chat"}, {}) is True
def test_a_deployment_the_catalog_never_described_is_not_mapped(self):
assert deployment_is_catalog_mapped(None, {}) is False
assert deployment_is_catalog_mapped({"max_input_tokens": 200000}, {}) is False
def test_a_mode_the_operator_wrote_does_not_make_the_deployment_mapped(self):
# Every deployment is registered in the cost map under its own id, so an operator-written
# mode reads back identically to one the catalog supplied and would otherwise let an
# off-map deployment empty the levels its mapped siblings agree on.
assert deployment_is_catalog_mapped({"mode": "chat"}, {"mode": "chat", "id": "abc"}) is False
class TestProvenanceSeparatesUnknownFromNonReasoning:
def test_an_off_map_deployment_resolves_to_unknown(self):
# get_model_info answers supports_reasoning None both for a deployment the map never
# described and for a mapped non-reasoning model, so reading an unset flag as () would let
# one custom deployment empty every level its mapped siblings agree on.
assert resolve_supported_reasoning_efforts({}, deployment_is_mapped=False) is None
assert resolve_supported_reasoning_efforts({"supports_reasoning": None}, deployment_is_mapped=False) is None
def test_a_mapped_deployment_the_map_calls_non_reasoning_supports_no_efforts(self):
assert resolve_supported_reasoning_efforts({}, deployment_is_mapped=True) == ()
assert resolve_supported_reasoning_efforts({"supports_reasoning": None}, deployment_is_mapped=True) == ()
def test_an_explicit_false_supports_no_efforts_off_the_map_too(self):
# The operator's own escape hatch: saying so on an off-map deployment must still empty the
# group, since nothing else can tell the resolver that model takes no effort level.
assert resolve_supported_reasoning_efforts({"supports_reasoning": False}, deployment_is_mapped=False) == ()
class TestResolveSupportedReasoningEfforts:
def test_no_metadata_resolves_to_unknown(self):
assert resolve_supported_reasoning_efforts({}) is None
def test_non_reasoning_model_supports_no_efforts(self):
assert resolve_supported_reasoning_efforts({"supports_reasoning": False}) == ()
assert resolve_supported_reasoning_efforts({"mode": "chat", "supports_reasoning": None}) == ()
def test_unset_flag_on_an_entry_with_no_mode_resolves_to_unknown(self):
# The router registers a deployment absent from the model map under a synthesized entry, and
# get_model_info then answers with supports_reasoning None just as it does for a mapped
# non-reasoning model. Only the missing mode tells them apart, and reading the synthesized
# one as () would let one custom model empty every level its mapped siblings agree on.
assert resolve_supported_reasoning_efforts({"supports_reasoning": None}) is None
assert resolve_supported_reasoning_efforts({"mode": None, "supports_reasoning": None}) is None
def test_reasoning_model_with_no_flags_gets_the_opt_out_levels_only(self):
# The kimi shape: supports_reasoning true, zero effort flags. medium/high are unconditional,
# none/minimal/low are opt-out so absence means supported, xhigh/max are opt-in so absence
# means unsupported.
assert resolve_supported_reasoning_efforts({"supports_reasoning": True}) == (
"none",
"minimal",
"low",
"medium",
"high",
)
def test_a_reasoning_model_with_no_flags_at_all_resolves_to_unknown(self):
# 689 of the map's 854 reasoning entries carry no effort flag, and the o-series, xai and
# bedrock nova entries among them accept neither none nor minimal, so composing a set out of
# the opt-out defaults alone would advertise levels those providers reject.
assert resolve_supported_reasoning_efforts({"supports_reasoning": True}, deployment_is_mapped=True) is None
def test_explicit_false_removes_an_opt_out_level(self):
# The gpt-5.5-pro shape from the model map: only medium/high/xhigh are accepted upstream.
@ -43,7 +56,8 @@ class TestResolveSupportedReasoningEfforts:
"supports_minimal_reasoning_effort": False,
"supports_low_reasoning_effort": False,
"supports_xhigh_reasoning_effort": True,
}
},
deployment_is_mapped=True,
)
assert resolved == ("medium", "high", "xhigh")
@ -54,47 +68,100 @@ class TestResolveSupportedReasoningEfforts:
"supports_reasoning": True,
"supports_xhigh_reasoning_effort": True,
"supports_max_reasoning_effort": True,
}
},
deployment_is_mapped=True,
)
assert resolved == ("none", "minimal", "low", "medium", "high", "xhigh", "max")
def test_ultra_is_opt_in(self):
without_flag = resolve_supported_reasoning_efforts({"supports_reasoning": True})
with_flag = resolve_supported_reasoning_efforts(
{"supports_reasoning": True, "supports_ultra_reasoning_effort": True}
)
assert without_flag is not None and "ultra" not in without_flag
assert with_flag is not None and with_flag[-1] == "ultra"
def test_opt_in_flag_set_false_stays_excluded(self):
resolved = resolve_supported_reasoning_efforts(
{"supports_reasoning": True, "supports_xhigh_reasoning_effort": False}
{
"supports_reasoning": True,
"supports_minimal_reasoning_effort": True,
"supports_xhigh_reasoning_effort": False,
},
deployment_is_mapped=True,
)
assert resolved is not None
assert "xhigh" not in resolved
assert resolved == ("none", "minimal", "low", "medium", "high")
class TestBareModelNameFallback:
def test_a_prefixed_entry_inherits_the_flags_of_its_unprefixed_twin(self):
"""azure/gpt-5-mini carries no effort flag while gpt-5-mini carries three, and the request
path resolves capability flags through that same twin (#20885). Reading only the prefixed
entry would answer unknown for a model the map fully describes."""
from litellm.utils import _get_model_info_helper
model_info = dict(_get_model_info_helper(model="gpt-5-mini", custom_llm_provider="azure"))
assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == (
"minimal",
"low",
"medium",
"high",
)
def test_the_prefixed_entry_wins_over_its_twin_per_flag(self):
resolved = resolve_supported_reasoning_efforts(
{
"supports_reasoning": True,
"litellm_provider": "azure",
"key": "azure/gpt-5-mini",
"supports_xhigh_reasoning_effort": True,
},
deployment_is_mapped=True,
)
assert resolved == ("minimal", "low", "medium", "high", "xhigh")
class TestNoneLevelPolarity:
def test_none_stays_opt_out_off_azure(self):
resolved = resolve_supported_reasoning_efforts(
{"supports_reasoning": True, "litellm_provider": "openai", "key": "gpt-5-mini"}
{
"supports_reasoning": True,
"litellm_provider": "openai",
"key": "openai/some-reasoner",
"supports_max_reasoning_effort": True,
},
deployment_is_mapped=True,
)
assert resolved is not None and "none" in resolved
def test_azure_without_the_flag_does_not_advertise_none(self):
resolved = resolve_supported_reasoning_efforts(
{"supports_reasoning": True, "litellm_provider": "azure", "key": "azure/unmapped-deployment"}
)
assert resolved == ("minimal", "low", "medium", "high")
def test_azure_with_the_flag_advertises_none(self):
def test_none_stays_opt_out_on_an_azure_model_outside_the_gpt_5_family(self):
"""AzureOpenAIGPT5Config is selected by is_model_gpt_5_model, so an azure o-series or
anthropic deployment never reaches the gate that refuses none and must keep the level."""
resolved = resolve_supported_reasoning_efforts(
{
"supports_reasoning": True,
"litellm_provider": "azure",
"key": "azure/o3",
"supports_max_reasoning_effort": True,
},
deployment_is_mapped=True,
)
assert resolved is not None and "none" in resolved
def test_azure_gpt_5_without_the_flag_does_not_advertise_none(self):
resolved = resolve_supported_reasoning_efforts(
{
"supports_reasoning": True,
"litellm_provider": "azure",
"key": "azure/gpt-5-turbo",
"supports_minimal_reasoning_effort": True,
},
deployment_is_mapped=True,
)
assert resolved == ("minimal", "low", "medium", "high")
def test_azure_gpt_5_with_the_flag_advertises_none(self):
resolved = resolve_supported_reasoning_efforts(
{
"supports_reasoning": True,
"litellm_provider": "azure",
"key": "azure/gpt-5-turbo",
"supports_none_reasoning_effort": True,
"key": "azure/unmapped-deployment",
}
},
deployment_is_mapped=True,
)
assert resolved is not None and "none" in resolved
@ -109,7 +176,7 @@ class TestNoneLevelPolarity:
from litellm.utils import _get_model_info_helper
model_info = dict(_get_model_info_helper(model=model_key.split("/", 1)[1], custom_llm_provider="azure"))
resolved = resolve_supported_reasoning_efforts(model_info)
resolved = resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True)
assert resolved is not None
gate_accepts_none = AzureOpenAIGPT5Config._supports_reasoning_effort_level(model_key, "none")

View file

@ -8994,6 +8994,7 @@ def test_model_group_info_reasoning_efforts_empty_on_a_mapped_non_reasoning_depl
"litellm_provider": "anthropic",
"mode": "chat",
"supports_reasoning": True,
"supports_max_reasoning_effort": True,
}
return {"key": model_name, "litellm_provider": "openai", "mode": "chat", "supports_reasoning": None}
@ -9032,6 +9033,7 @@ def test_model_group_info_reasoning_efforts_ignore_a_value_declared_in_model_inf
"litellm_provider": "openai",
"mode": "chat",
"supports_reasoning": True,
"supports_none_reasoning_effort": True,
}
if model_id == "first-deployment":
info["supported_reasoning_efforts"] = ("high",)
@ -9047,26 +9049,39 @@ def test_model_group_info_reasoning_efforts_ignore_a_value_declared_in_model_inf
assert result.supported_reasoning_efforts == ("none", "minimal", "low", "medium", "high")
@pytest.mark.parametrize(
"configured, expected",
[
("high", ("high",)),
(["low", "high"], ("low", "high")),
(17, None),
({"effort": "high"}, None),
([1, 2], None),
],
)
def test_model_group_info_tolerates_any_configured_reasoning_efforts_shape(configured, expected):
"""Deployment model_info is splatted into ModelGroupInfo, so this key arrives with whatever an
operator wrote in the config. A shape pydantic cannot validate used to fail the whole
/model_group/info response, not just the group carrying it."""
from litellm.types.router import ModelGroupInfo
def test_model_group_info_reasoning_efforts_ignore_a_mode_the_operator_declared():
"""A deployment is registered in the cost map under its own id with whatever model_info the
operator wrote, so a mode they set themselves reads back exactly like one the map supplied. Only
a mode the map supplied marks the deployment as known, or an off-map deployment carrying any
mode empties the group it sits in."""
from litellm.router_utils.reasoning_effort_capability import resolve_supported_reasoning_efforts
info = ModelGroupInfo(
model_group="g",
providers=["openai"],
supported_reasoning_efforts=configured,
mapped_model = "openai/gpt-5.6-sol"
expected = resolve_supported_reasoning_efforts(
litellm.get_model_info(model=mapped_model),
deployment_is_mapped=True,
)
assert expected
router = litellm.Router(
model_list=[
{
"model_name": "smart-group",
"litellm_params": {"model": mapped_model, "api_key": "sk-fake"},
"model_info": {"id": "mapped-deployment"},
},
{
"model_name": "smart-group",
"litellm_params": {"model": "openai/a-model-the-map-never-heard-of", "api_key": "sk-fake"},
"model_info": {"id": "off-map-deployment", "mode": "chat"},
},
]
)
assert info.supported_reasoning_efforts == expected
result = router._set_model_group_info(
model_group="smart-group",
user_facing_model_group_name="smart-group",
)
assert result is not None
assert result.supported_reasoning_efforts == expected

View file

@ -997,7 +997,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"supports_none_reasoning_effort": {"type": "boolean"},
"supports_xhigh_reasoning_effort": {"type": "boolean"},
"supports_max_reasoning_effort": {"type": "boolean"},
"supports_ultra_reasoning_effort": {"type": "boolean"},
"supports_adaptive_thinking": {"type": "boolean"},
"supports_legacy_thinking": {"type": "boolean"},
"thinking_always_on": {"type": "boolean"},

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { tierEffortRows } from "./TierModelEffortRows";
describe("tierEffortRows", () => {
it("offers the levels the proxy reports for the model", () => {
const rows = tierEffortRows({
models: ["gpt-5-mini"],
effortOptionsByModel: { "gpt-5-mini": ["low", "medium", "high"] },
paramsByModel: undefined,
});
expect(rows).toEqual([{ model: "gpt-5-mini", effort: undefined, options: ["low", "medium", "high"] }]);
});
it("keeps a row whose model reports no level but already has an effort stored, so it can be cleared", () => {
const rows = tierEffortRows({
models: ["off-map-model"],
effortOptionsByModel: {},
paramsByModel: { "off-map-model": { reasoning_effort: "high" } },
});
expect(rows).toEqual([{ model: "off-map-model", effort: "high", options: ["high"] }]);
});
it("drops a row whose model reports no level and has nothing stored", () => {
const rows = tierEffortRows({
models: ["plain-chat-model"],
effortOptionsByModel: { "plain-chat-model": [] },
paramsByModel: { "plain-chat-model": { temperature: 0.5 } },
});
expect(rows).toEqual([]);
});
it("lists a stored level the model no longer reports without duplicating the ones it does", () => {
const rows = tierEffortRows({
models: ["gpt-5-mini"],
effortOptionsByModel: { "gpt-5-mini": ["low", "high"] },
paramsByModel: { "gpt-5-mini": { reasoning_effort: "xhigh" } },
});
expect(rows).toEqual([{ model: "gpt-5-mini", effort: "xhigh", options: ["low", "high", "xhigh"] }]);
});
it("does not repeat a stored level the model already reports", () => {
const rows = tierEffortRows({
models: ["gpt-5-mini"],
effortOptionsByModel: { "gpt-5-mini": ["low", "high"] },
paramsByModel: { "gpt-5-mini": { reasoning_effort: "high" } },
});
expect(rows).toEqual([{ model: "gpt-5-mini", effort: "high", options: ["low", "high"] }]);
});
it.each([
["an unset key", {}],
["an explicit null", { reasoning_effort: null }],
["an empty string", { reasoning_effort: "" }],
])("reads %s as no stored effort", (_label, params) => {
const rows = tierEffortRows({
models: ["gpt-5-mini"],
effortOptionsByModel: { "gpt-5-mini": ["low"] },
paramsByModel: { "gpt-5-mini": params },
});
expect(rows[0].effort).toBeUndefined();
});
it("renders a non-string stored value as a string so the select can show and clear it", () => {
const rows = tierEffortRows({
models: ["gpt-5-mini"],
effortOptionsByModel: { "gpt-5-mini": ["low"] },
paramsByModel: { "gpt-5-mini": { reasoning_effort: 3 } },
});
expect(rows).toEqual([{ model: "gpt-5-mini", effort: "3", options: ["low", "3"] }]);
});
});

View file

@ -8,7 +8,8 @@ const PROVIDER_DEFAULT = "__provider_default__";
const storedEffort = (params: TierModelParams | undefined): ReasoningEffort | undefined => {
const stored = params?.reasoning_effort;
return typeof stored === "string" && stored ? stored : undefined;
if (stored === undefined || stored === null || stored === "") return undefined;
return typeof stored === "string" ? stored : String(stored);
};
interface TierModelEffortRowsProps {
@ -19,6 +20,31 @@ interface TierModelEffortRowsProps {
onEffortChange: (model: string, effort: ReasoningEffort | undefined) => void;
}
export interface TierEffortRow {
model: string;
effort: ReasoningEffort | undefined;
options: string[];
}
/**
* A stored effort outside the model's supported set (hand-authored, or capabilities changed since
* it was saved) is listed anyway, so the row renders with its value selected and can be cleared.
* Only a model with no supported level and nothing stored drops out.
*/
export const tierEffortRows = ({
models,
effortOptionsByModel,
paramsByModel,
}: Pick<TierModelEffortRowsProps, "models" | "effortOptionsByModel" | "paramsByModel">): TierEffortRow[] =>
models
.map((model) => {
const effort = storedEffort(paramsByModel?.[model]);
const supported = effortOptionsByModel[model] ?? [];
const listed = effort !== undefined && !supported.includes(effort) ? [...supported, effort] : supported;
return { model, effort, options: Array.from(new Set(listed)) };
})
.filter(({ options }) => options.length > 0);
const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = ({
tierLabel,
models,
@ -26,16 +52,7 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = ({
paramsByModel,
onEffortChange,
}) => {
const rows = models
.map((model) => {
const effort = storedEffort(paramsByModel?.[model]);
const supported = effortOptionsByModel[model] ?? [];
// A stored effort outside the supported set (hand-authored, or capabilities changed since it
// was saved) stays listed so it renders and can be cleared.
const options = effort !== undefined && !supported.includes(effort) ? [...supported, effort] : supported;
return { model, effort, options };
})
.filter(({ model, options }) => options.length > 0 || Object.keys(paramsByModel?.[model] ?? {}).length > 0);
const rows = tierEffortRows({ models, effortOptionsByModel, paramsByModel });
if (rows.length === 0) return null;
return (
<div className="mt-2 space-y-1">

View file

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { modelAvailableCall } from "@/components/networking";
import { fetchAvailableModelsForTeam } from "./fetch_models";
import { modelAvailableCall, modelHubCall } from "@/components/networking";
import { fetchAvailableModels, fetchAvailableModelsForTeam } from "./fetch_models";
vi.mock("@/components/networking", () => ({
modelAvailableCall: vi.fn(),
@ -8,6 +8,7 @@ vi.mock("@/components/networking", () => ({
}));
const modelAvailableCallMock = vi.mocked(modelAvailableCall);
const modelHubCallMock = vi.mocked(modelHubCall);
describe("fetchAvailableModelsForTeam", () => {
beforeEach(() => {
@ -31,3 +32,33 @@ describe("fetchAvailableModelsForTeam", () => {
expect(await fetchAvailableModelsForTeam("token", "team-123")).toEqual([]);
});
});
describe("fetchAvailableModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("carries the reasoning capabilities the model hub reports for each group", async () => {
modelHubCallMock.mockResolvedValue({
data: [
{ model_group: "smart", mode: "chat", supports_reasoning: true, supported_reasoning_efforts: ["low", "high"] },
{ model_group: "plain", mode: "chat", supports_reasoning: false },
],
});
expect(await fetchAvailableModels("token")).toEqual([
{ model_group: "plain", mode: "chat" },
{ model_group: "smart", mode: "chat", supports_reasoning: true, supported_reasoning_efforts: ["low", "high"] },
]);
});
it.each([
["an error payload in place of the list", { data: { error: "no access" } }],
["a missing data key", {}],
["no body at all", undefined],
])("returns an empty list on %s rather than throwing", async (_label, response) => {
modelHubCallMock.mockResolvedValue(response);
expect(await fetchAvailableModels("token")).toEqual([]);
});
});

View file

@ -44,7 +44,8 @@ export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: s
export const fetchAvailableModels = async (accessToken: string): Promise<ModelGroup[]> => {
try {
const fetchedModels = await modelHubCall(accessToken);
const models: ModelGroup[] = (fetchedModels?.data ?? [])
const fetchedData: unknown = fetchedModels?.data;
const models: ModelGroup[] = (Array.isArray(fetchedData) ? fetchedData : [])
.map(toModelGroup)
.filter((model: ModelGroup) => model.model_group !== "")
.sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group));