diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index b94e91b3034..f94bf34e8d3 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1113,22 +1113,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true" ) - # If string is passed, map with optional summary based on flag/env var - if reasoning_effort == "none": - return Reasoning(effort="none", summary="detailed") if auto_summary_enabled else Reasoning(effort="none") - elif reasoning_effort == "high": - return Reasoning(effort="high", summary="detailed") if auto_summary_enabled else Reasoning(effort="high") - elif reasoning_effort == "xhigh": - return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh") - elif reasoning_effort == "medium": + # Level-agnostic: providers own effort validation, so an unknown level (max, ultra, future + # ones) passes through instead of being silently dropped here. + if reasoning_effort: return ( - Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium") - ) - elif reasoning_effort == "low": - return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low") - elif reasoning_effort == "minimal": - return ( - Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal") + Reasoning(effort=reasoning_effort, summary="detailed") + if auto_summary_enabled + else Reasoning(effort=reasoning_effort) ) return None diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index ffa3de0d5c6..c55640f6a1f 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -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 string: 'none', 'low', 'medium', 'high', or 'xhigh'. + The chat completion API expects a simple effort string ('none' through 'ultra'). Config/deployments may pass the Responses API format: {'effort': 'high', 'summary': 'detailed'}. """ if value is None: @@ -222,8 +222,8 @@ 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 effective_effort in ("xhigh", "max", "ultra"): + # xhigh/max/ultra are opt-in capabilities: only allow if the model explicitly supports them. if not self._supports_reasoning_effort_level(model, effective_effort): if litellm.drop_params or drop_params: non_default_params.pop("reasoning_effort", None) diff --git a/litellm/main.py b/litellm/main.py index 84931c63544..1cac723c179 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -416,7 +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", "default"] | None = None, + reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default"] | None = None, verbosity: Literal["low", "medium", "high"] | None = None, safety_identifier: str | None = None, service_tier: str | None = None, @@ -4920,7 +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", "default"] | None = None, + reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default"] | None = None, verbosity: Literal["low", "medium", "high"] | None = None, response_format: dict | type[BaseModel] | None = None, seed: int | None = None, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c36018feb9d..4a275273fb2 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -6639,7 +6639,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-sol": { "cache_read_input_token_cost": 5e-07, @@ -6690,7 +6692,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-terra": { "cache_read_input_token_cost": 2e-07, @@ -6741,7 +6745,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-luna": { "cache_read_input_token_cost": 2e-08, @@ -6792,7 +6798,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6": { "cache_read_input_token_cost": 5.5e-07, @@ -6839,7 +6847,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-sol": { "cache_read_input_token_cost": 5.5e-07, @@ -6887,7 +6897,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-terra": { "cache_read_input_token_cost": 2.2e-07, @@ -6935,7 +6947,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-luna": { "cache_read_input_token_cost": 2.2e-08, @@ -6983,7 +6997,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6": { "cache_read_input_token_cost": 5.5e-07, @@ -7030,7 +7046,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-sol": { "cache_read_input_token_cost": 5.5e-07, @@ -7078,7 +7096,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-terra": { "cache_read_input_token_cost": 2.2e-07, @@ -7126,7 +7146,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-luna": { "cache_read_input_token_cost": 2.2e-08, @@ -7174,7 +7196,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.5": { "deprecation_date": "2027-10-26", @@ -26405,7 +26429,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-sol": { "cache_creation_input_token_cost": 5e-06, @@ -26469,7 +26495,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-terra": { "cache_creation_input_token_cost": 2.5e-06, @@ -26532,7 +26560,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-luna": { "cache_creation_input_token_cost": 2.5e-07, @@ -26595,7 +26625,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-cyber": { "cache_creation_input_token_cost": 1.5625e-05, @@ -49028,7 +49060,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "bedrock_mantle/openai.gpt-5.6-terra": { "input_cost_per_token": 2.2e-06, @@ -49060,7 +49094,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "bedrock_mantle/openai.gpt-5.6-luna": { "input_cost_per_token": 2.2e-07, @@ -49092,7 +49128,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "us.openai.gpt-5.6-sol": { "input_cost_per_token": 5.5e-06, diff --git a/litellm/router.py b/litellm/router.py index 045fd32847c..8cd1b804928 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -167,6 +167,10 @@ from litellm.router_utils.pre_call_checks.model_rate_limit_check import ( from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import ( PromptCachingDeploymentCheck, ) +from litellm.router_utils.reasoning_effort_capability import ( + intersect_supported_reasoning_efforts, + resolve_supported_reasoning_efforts, +) from litellm.router_utils.router_callbacks.track_deployment_metrics import ( increment_deployment_failures_for_current_minute, increment_deployment_successes_for_current_minute, @@ -9558,6 +9562,11 @@ class Router: if model_info.get("rpm", None) is not None and _deployment_rpm is None: _deployment_rpm = model_info.get("rpm") + model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts( + model_group_info.supported_reasoning_efforts, + resolve_supported_reasoning_efforts(model_info), + ) + if _deployment_tpm is not None: if total_tpm is None: total_tpm = 0 diff --git a/litellm/router_utils/reasoning_effort_capability.py b/litellm/router_utils/reasoning_effort_capability.py new file mode 100644 index 00000000000..f59c53e9287 --- /dev/null +++ b/litellm/router_utils/reasoning_effort_capability.py @@ -0,0 +1,53 @@ +"""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; +anthropic/chat/transformation.py rejects only xhigh/max without an explicit flag): medium and high +are unconditional for any reasoning model, none/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. +""" + +from collections.abc import Mapping, Sequence +from typing import Final + +REASONING_EFFORT_CAPABILITY_ORDER: Final = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") + +_OPT_OUT_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"), +) +_UNCONDITIONAL_EFFORTS: Final = frozenset(("medium", "high")) + + +def resolve_supported_reasoning_efforts(model_info: Mapping[str, object]) -> tuple[str, ...] | None: + """None = no capability metadata for this deployment (e.g. a model absent from the model map, + whose stub info carries no supports_reasoning key at all); () = reasoning unsupported.""" + if "supports_reasoning" not in model_info: + return None + if model_info.get("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) + allowed: Final = opt_out | _UNCONDITIONAL_EFFORTS | opt_in + return tuple(effort for effort in REASONING_EFFORT_CAPABILITY_ORDER if effort in allowed) + + +def intersect_supported_reasoning_efforts( + current: Sequence[str] | None, + resolved: Sequence[str] | None, +) -> tuple[str, ...] | None: + """Deployments without metadata (None) never narrow the group; an effort survives only when + every deployment with metadata accepts it, so the group offers nothing routing could reject.""" + if resolved is None: + return tuple(current) if current is not None else None + 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) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index e7a3f825455..e3d28a0097f 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1840,7 +1840,7 @@ ResponsesAPIStreamingResponse = Annotated[ ] -REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh"] +REASONING_EFFORT = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] class OpenAIRealtimeStreamSession(TypedDict, total=False): diff --git a/litellm/types/router.py b/litellm/types/router.py index 9fd5cfa96ef..d4c735387a5 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -637,6 +637,7 @@ class ModelGroupInfo(BaseModel): supports_url_context: bool = Field(default=False) supports_reasoning: bool = Field(default=False) supports_function_calling: bool = Field(default=False) + supported_reasoning_efforts: tuple[str, ...] | None = Field(default=None) supported_openai_params: list[str] | None = Field(default=[]) configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 4d59650f410..6a5e4c24dce 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -164,6 +164,7 @@ 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 diff --git a/litellm/utils.py b/litellm/utils.py index 5b2ef93edb3..a74cf23a4ae 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5764,6 +5764,7 @@ 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), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c36018feb9d..4a275273fb2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -6639,7 +6639,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-sol": { "cache_read_input_token_cost": 5e-07, @@ -6690,7 +6692,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-terra": { "cache_read_input_token_cost": 2e-07, @@ -6741,7 +6745,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.6-luna": { "cache_read_input_token_cost": 2e-08, @@ -6792,7 +6798,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6": { "cache_read_input_token_cost": 5.5e-07, @@ -6839,7 +6847,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-sol": { "cache_read_input_token_cost": 5.5e-07, @@ -6887,7 +6897,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-terra": { "cache_read_input_token_cost": 2.2e-07, @@ -6935,7 +6947,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/us/gpt-5.6-luna": { "cache_read_input_token_cost": 2.2e-08, @@ -6983,7 +6997,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6": { "cache_read_input_token_cost": 5.5e-07, @@ -7030,7 +7046,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-sol": { "cache_read_input_token_cost": 5.5e-07, @@ -7078,7 +7096,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-terra": { "cache_read_input_token_cost": 2.2e-07, @@ -7126,7 +7146,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/eu/gpt-5.6-luna": { "cache_read_input_token_cost": 2.2e-08, @@ -7174,7 +7196,9 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": false + "supports_minimal_reasoning_effort": false, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "azure/gpt-5.5": { "deprecation_date": "2027-10-26", @@ -26405,7 +26429,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-sol": { "cache_creation_input_token_cost": 5e-06, @@ -26469,7 +26495,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-terra": { "cache_creation_input_token_cost": 2.5e-06, @@ -26532,7 +26560,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-luna": { "cache_creation_input_token_cost": 2.5e-07, @@ -26595,7 +26625,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "gpt-5.6-cyber": { "cache_creation_input_token_cost": 1.5625e-05, @@ -49028,7 +49060,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "bedrock_mantle/openai.gpt-5.6-terra": { "input_cost_per_token": 2.2e-06, @@ -49060,7 +49094,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "bedrock_mantle/openai.gpt-5.6-luna": { "input_cost_per_token": 2.2e-07, @@ -49092,7 +49128,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_max_reasoning_effort": true, + "supports_ultra_reasoning_effort": true }, "us.openai.gpt-5.6-sol": { "input_cost_per_token": 5.5e-06, diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index f7f60c7666d..75bf3d47f35 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -702,6 +702,9 @@ "supports_tool_search": { "type": "boolean" }, + "supports_ultra_reasoning_effort": { + "type": "boolean" + }, "supports_url_context": { "type": "boolean" }, diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 1fb74b2b7bf..a0aecfafdbc 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1585,10 +1585,15 @@ 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: None/unknown values return None - result_unknown = handler._map_reasoning_effort("unknown_value") - assert result_unknown is None - print("✓ Unknown reasoning_effort values return None") + # 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 + from litellm.types.llms.openai import Reasoning + + for effort in ("max", "ultra", "unknown_value"): + result_passthrough = handler._map_reasoning_effort(effort) + assert result_passthrough == Reasoning(effort=effort) + assert handler._map_reasoning_effort("") is None + print("✓ Unenumerated reasoning_effort levels pass through to the provider") print( "✓ All reasoning_effort behaviors work correctly with flag/env var control" diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index d279b119efe..7595c64da07 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -1309,3 +1309,36 @@ def test_responses_gpt54_allow_temperature_effort_none( drop_params=False, ) assert params["temperature"] == 0.7 + + +@pytest.mark.parametrize("effort", ["max", "ultra"]) +def test_gpt5_6_allows_opt_in_reasoning_efforts(config: OpenAIConfig, effort: str): + params = config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="gpt-5.6", + drop_params=False, + ) + assert params["reasoning_effort"] == effort + + +@pytest.mark.parametrize("effort", ["max", "ultra"]) +def test_gpt5_rejects_opt_in_reasoning_efforts_for_other_models(config: OpenAIConfig, effort: str): + with pytest.raises(litellm.utils.UnsupportedParamsError): + config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="gpt-5.1", + drop_params=False, + ) + + +@pytest.mark.parametrize("effort", ["max", "ultra"]) +def test_gpt5_drops_opt_in_reasoning_efforts_when_requested(config: OpenAIConfig, effort: str): + params = config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="gpt-5.1", + drop_params=True, + ) + assert "reasoning_effort" not in params diff --git a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py index 9177944df2d..a906b0e638f 100644 --- a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py @@ -1352,7 +1352,9 @@ 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), - ("foo-thinking-ultra-fast", "foo-thinking-ultra", None), + ("gpt-5.6-thinking-ultra-fast", "gpt-5.6", "ultra"), + ("gpt-5.6-thinking-max", "gpt-5.6", "max"), + ("foo-thinking-mega-fast", "foo-thinking-mega", None), ("-thinking-high", "-thinking-high", None), ], ) diff --git a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py new file mode 100644 index 00000000000..ebc7952081c --- /dev/null +++ b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py @@ -0,0 +1,79 @@ +from litellm.router_utils.reasoning_effort_capability import ( + intersect_supported_reasoning_efforts, + resolve_supported_reasoning_efforts, +) + + +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": None}) == () + assert resolve_supported_reasoning_efforts({"supports_reasoning": False}) == () + + 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_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. + resolved = resolve_supported_reasoning_efforts( + { + "supports_reasoning": True, + "supports_none_reasoning_effort": False, + "supports_minimal_reasoning_effort": False, + "supports_low_reasoning_effort": False, + "supports_xhigh_reasoning_effort": True, + } + ) + assert resolved == ("medium", "high", "xhigh") + + def test_explicit_true_adds_the_opt_in_levels(self): + # The claude-opus shape: xhigh and max explicitly true, everything else absent. + resolved = resolve_supported_reasoning_efforts( + { + "supports_reasoning": True, + "supports_xhigh_reasoning_effort": True, + "supports_max_reasoning_effort": 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} + ) + assert resolved is not None + assert "xhigh" not in resolved + + +class TestIntersectSupportedReasoningEfforts: + def test_unknown_never_narrows(self): + assert intersect_supported_reasoning_efforts(["medium", "high"], None) == ("medium", "high") + assert intersect_supported_reasoning_efforts(None, ["medium", "high"]) == ("medium", "high") + assert intersect_supported_reasoning_efforts(None, None) is None + + def test_intersection_keeps_canonical_order(self): + assert intersect_supported_reasoning_efforts( + ["max", "high", "medium", "xhigh"], ["xhigh", "medium", "minimal"] + ) == ("medium", "xhigh") + + def test_disjoint_sets_intersect_to_empty(self): + assert intersect_supported_reasoning_efforts(["max"], ["minimal"]) == () diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d00fbf589e3..095c962328a 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -8878,3 +8878,87 @@ class TestAzureBaseModelFallbackLogging: deployment=None, received_model_name="my-group", id="azure-base-model-test-id" ) assert model_info["max_input_tokens"] == litellm.model_cost["azure/gpt-4o-mini"]["max_input_tokens"] + +def test_model_group_info_intersects_supported_reasoning_efforts(): + router = litellm.Router( + model_list=[ + { + "model_name": "smart-group", + "litellm_params": {"model": "anthropic/opus-like"}, + "model_info": {"id": "opus-like-deployment"}, + }, + { + "model_name": "smart-group", + "litellm_params": {"model": "openai/mini-like"}, + "model_info": {"id": "mini-like-deployment"}, + }, + ] + ) + + def _model_info(model_id: str, model_name: str): + if model_id == "opus-like-deployment": + return { + "key": model_name, + "litellm_provider": "anthropic", + "mode": "chat", + "supports_reasoning": True, + "supports_xhigh_reasoning_effort": True, + "supports_max_reasoning_effort": True, + } + return { + "key": model_name, + "litellm_provider": "openai", + "mode": "chat", + "supports_reasoning": True, + "supports_none_reasoning_effort": False, + "supports_minimal_reasoning_effort": True, + "supports_xhigh_reasoning_effort": False, + } + + with patch.object(router, "get_deployment_model_info", side_effect=_model_info): + result = router._set_model_group_info( + model_group="smart-group", + user_facing_model_group_name="smart-group", + ) + + assert result is not None + # opus-like offers all seven levels, mini-like lacks none/xhigh/max; only the common set survives, + # so the group never advertises an effort routing could hand to a deployment that rejects it. + assert result.supported_reasoning_efforts == ("minimal", "low", "medium", "high") + + +def test_model_group_info_reasoning_efforts_ignore_deployments_without_metadata(): + router = litellm.Router( + model_list=[ + { + "model_name": "smart-group", + "litellm_params": {"model": "anthropic/opus-like"}, + "model_info": {"id": "opus-like-deployment"}, + }, + { + "model_name": "smart-group", + "litellm_params": {"model": "openai/unmapped-model"}, + "model_info": {"id": "unmapped-deployment"}, + }, + ] + ) + + def _model_info(model_id: str, model_name: str): + if model_id == "opus-like-deployment": + return { + "key": model_name, + "litellm_provider": "anthropic", + "mode": "chat", + "supports_reasoning": True, + "supports_max_reasoning_effort": True, + } + return {"key": model_name, "litellm_provider": "openai", "mode": "chat"} + + with patch.object(router, "get_deployment_model_info", side_effect=_model_info): + 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 == ("none", "minimal", "low", "medium", "high", "max") diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 27cf9067914..af69d117e04 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -997,6 +997,7 @@ 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"}, diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 0a848ed7deb..a26197aa243 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -8,7 +8,12 @@ vi.mock( ); const mockModelInfo = [ - { model_group: "gpt-4", mode: "chat", supports_reasoning: true }, + { + model_group: "gpt-4", + mode: "chat", + supports_reasoning: true, + supported_reasoning_efforts: ["medium", "high", "xhigh"], + }, { model_group: "gpt-3.5-turbo", mode: "chat" }, { model_group: "claude-3-opus", mode: "chat", supports_reasoning: true }, { model_group: "text-embedding-3-small", mode: "embedding" }, @@ -943,3 +948,37 @@ describe("ComplexityRouterConfig reasoning effort gating", () => { ).toHaveTextContent("low"); }); }); + +describe("ComplexityRouterConfig per-model effort filtering", () => { + it("offers only the efforts the model group supports", async () => { + renderWithProviders(); + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" })); + const options = (await screen.findAllByRole("option")).map((option) => option.textContent); + expect(options).toEqual(["Default", "medium", "high", "xhigh"]); + }); + + it("falls back to every effort when the group only reports supports_reasoning", async () => { + renderWithProviders(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("combobox", { name: "Reasoning effort for claude-3-opus in the Reasoning tier" }), + ); + const options = (await screen.findAllByRole("option")).map((option) => option.textContent); + expect(options).toEqual(["Default", "none", "minimal", "low", "medium", "high", "xhigh"]); + }); + + // Hand-authored configs can carry a level outside the supported set (e.g. max); it must render + // and stay clearable rather than being masked as Default. + it("keeps showing a stored effort outside the supported set", () => { + renderWithProviders( + , + ); + expect(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" })).toHaveTextContent( + "max", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index fc731e2c77f..e72905c2f73 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -13,6 +13,7 @@ import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; import ClassificationMethodConfig from "./ClassificationMethodConfig"; import { + REASONING_EFFORT_OPTIONS, ReasoningEffort, TierModelParamsByTier, pruneTierModelParams, @@ -251,8 +252,13 @@ const ComplexityRouterConfig: React.FC = ({ const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); // Embedding models can't serve a chat-completion role, so they're excluded here. - const reasoningModels = new Set( - modelInfo.filter((model) => model.supports_reasoning).map((model) => model.model_group), + // The backend list is the per-group intersection of accepted effort levels; when a proxy does not + // send it yet, fall back to the coarse supports_reasoning gate with every level offered. + const effortOptionsByModel: Record = Object.fromEntries( + modelInfo.map((model) => [ + model.model_group, + model.supported_reasoning_efforts ?? (model.supports_reasoning ? [...REASONING_EFFORT_OPTIONS] : []), + ]), ); const modelOptions = modelInfo @@ -365,7 +371,7 @@ const ComplexityRouterConfig: React.FC = ({ handleTierModelEffortChange(tier, model, effort)} /> diff --git a/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx b/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx index 67f583bd894..0c93b3228dc 100644 --- a/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx +++ b/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx @@ -2,20 +2,19 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { SimpleTooltip } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; import React from "react"; -import { REASONING_EFFORT_OPTIONS, ReasoningEffort, TierModelParams } from "./complexity_router_tiers"; +import { ReasoningEffort, TierModelParams } from "./complexity_router_tiers"; const PROVIDER_DEFAULT = "__provider_default__"; -const asEffort = (params: TierModelParams | undefined): ReasoningEffort | undefined => { +const storedEffort = (params: TierModelParams | undefined): ReasoningEffort | undefined => { const stored = params?.reasoning_effort; - if (typeof stored !== "string") return undefined; - return REASONING_EFFORT_OPTIONS.find((option) => option === stored); + return typeof stored === "string" && stored ? stored : undefined; }; interface TierModelEffortRowsProps { tierLabel: string; models: string[]; - reasoningModels: ReadonlySet; + effortOptionsByModel: Record; paramsByModel: Record | undefined; onEffortChange: (model: string, effort: ReasoningEffort | undefined) => void; } @@ -23,14 +22,21 @@ interface TierModelEffortRowsProps { const TierModelEffortRows: React.FC = ({ tierLabel, models, - reasoningModels, + effortOptionsByModel, paramsByModel, onEffortChange, }) => { - const shown = models.filter( - (model) => reasoningModels.has(model) || Object.keys(paramsByModel?.[model] ?? {}).length > 0, - ); - if (shown.length === 0) return null; + 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); + if (rows.length === 0) return null; return ( @@ -41,18 +47,17 @@ const TierModelEffortRows: React.FC = ({ - {shown.map((model) => ( + {rows.map(({ model, effort, options }) => ( {model} ({ value: option, label: option })), + ...options.map((option) => ({ value: option, label: option })), ]} - value={asEffort(paramsByModel?.[model]) ?? PROVIDER_DEFAULT} + value={effort ?? PROVIDER_DEFAULT} onValueChange={(selected: string | null) => - selected !== null && - onEffortChange(model, selected === PROVIDER_DEFAULT ? undefined : (selected as ReasoningEffort)) + selected !== null && onEffortChange(model, selected === PROVIDER_DEFAULT ? undefined : selected) } > = ({ Default - {REASONING_EFFORT_OPTIONS.map((option) => ( + {options.map((option) => ( {option} diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts index cc12746a755..2ea1915ca03 100644 --- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts +++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts @@ -5,8 +5,18 @@ export type TierModelParams = Record; export type TierModelParamsByTier = Record>; +/** + * Fallback offered only when a proxy does not report supported_reasoning_efforts per model group. + * max is deliberately absent: it is opt-in per model in the model map, so a capability-blind list + * must not offer it; a proxy that reports capabilities supplies max itself where supported. + */ export const REASONING_EFFORT_OPTIONS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const; -export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number]; + +/** + * Open on purpose: the valid set is per model group at runtime (supported_reasoning_efforts), and + * hand-authored configs can carry any level, so the known literals only add autocompletion. + */ +export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number] | (string & {}); const asRecord = (raw: unknown): Record | undefined => typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record) : undefined; diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx index bd691c7f629..2f35cf5ef6c 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { modelAvailableCall } from "@/components/networking"; +import { modelAvailableCall, modelHubCall } from "@/components/networking"; import { fetchAvailableModelsForTeam } from "./fetch_models"; vi.mock("@/components/networking", () => ({ @@ -31,3 +31,38 @@ describe("fetchAvailableModelsForTeam", () => { expect(await fetchAvailableModelsForTeam("token", "team-123")).toEqual([]); }); }); + +describe("fetchAvailableModelsForTeam capability join", () => { + it("carries model-group capabilities onto team-allowed names that have a group entry", async () => { + modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-5-mini" }, { id: "team-only-byok" }] }); + vi.mocked(modelHubCall).mockResolvedValue({ + data: [ + { + model_group: "gpt-5-mini", + mode: "chat", + supports_reasoning: true, + supported_reasoning_efforts: ["minimal", "low", "medium", "high"], + }, + ], + }); + + const models = await fetchAvailableModelsForTeam("token", "team-123"); + + expect(models).toEqual([ + { + model_group: "gpt-5-mini", + mode: "chat", + supports_reasoning: true, + supported_reasoning_efforts: ["minimal", "low", "medium", "high"], + }, + { model_group: "team-only-byok" }, + ]); + }); + + it("keeps the team list usable when the group-info fetch fails", async () => { + modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-5-mini" }] }); + vi.mocked(modelHubCall).mockRejectedValue(new Error("boom")); + + expect(await fetchAvailableModelsForTeam("token", "team-123")).toEqual([{ model_group: "gpt-5-mini" }]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx index 4f4c901a1c2..93f89a44813 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -7,6 +7,7 @@ export interface ModelGroup { model_group: string; mode?: string; supports_reasoning?: boolean; + supported_reasoning_efforts?: string[]; } interface AvailableModel { @@ -15,15 +16,36 @@ interface AvailableModel { id?: string | null; mode?: string | null; supports_reasoning?: boolean | null; + supported_reasoning_efforts?: string[] | null; } +const toModelGroup = (item: AvailableModel): ModelGroup => { + const groupName = (item.model_group || item.id || item.model_name) ?? ""; + return { + model_group: groupName, + ...(item.mode && { mode: item.mode }), + ...(item.supports_reasoning === true && { supports_reasoning: true }), + ...(item.supported_reasoning_efforts && { supported_reasoning_efforts: item.supported_reasoning_efforts }), + }; +}; + +/** + * /models carries no capability metadata, so the team-allowed names are joined against + * /model_group/info (one extra parallel request; the endpoint has no team filter of its own, and it + * serves team-scoped tokens row-filtered, verified live). A name without a group entry keeps every + * capability field absent; a failed group fetch is console.error'd by fetchAvailableModels. + */ export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise => { - const response = await modelAvailableCall(accessToken, "", "", false, teamId); + const [response, groups] = await Promise.all([ + modelAvailableCall(accessToken, "", "", false, teamId), + fetchAvailableModels(accessToken).catch(() => [] as ModelGroup[]), + ]); + const byGroup = new Map(groups.map((group) => [group.model_group, group])); const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id); return excludeProxyWideSentinel(Array.from(new Set(modelNames))) .sort((a, b) => a.localeCompare(b)) - .map((model) => ({ model_group: model })); + .map((model) => byGroup.get(model) ?? { model_group: model }); }; /** @@ -32,20 +54,11 @@ export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: s export const fetchAvailableModels = async (accessToken: string): Promise => { try { const fetchedModels = await modelHubCall(accessToken); - - if (fetchedModels?.data.length > 0) { - const models: ModelGroup[] = fetchedModels.data - .map((item: AvailableModel) => ({ - model_group: item.model_group || item.id || item.model_name || "", - mode: item.mode || undefined, - supports_reasoning: item.supports_reasoning === true || undefined, - })) - .filter((model: ModelGroup) => model.model_group !== ""); - - models.sort((a, b) => a.model_group.localeCompare(b.model_group)); - return Array.from(new Map(models.map((model) => [model.model_group, model])).values()); - } - return []; + const models: ModelGroup[] = (fetchedModels?.data ?? []) + .map(toModelGroup) + .filter((model: ModelGroup) => model.model_group !== "") + .sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group)); + return Array.from(new Map(models.map((model) => [model.model_group, model])).values()); } catch (error) { console.error("Error fetching model info:", error); throw error; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8deec1ed897..31207a9ce91 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29526,6 +29526,8 @@ export interface components { * @default [] */ supported_openai_params: string[] | null; + /** Supported Reasoning Efforts */ + supported_reasoning_efforts?: string[] | null; /** * Supports Function Calling * @default false