diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 8e4b116d79f..7b0fcd24770 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -2,7 +2,7 @@ Translates from OpenAI's `/v1/chat/completions` to Moonshot AI's `/v1/chat/completions` """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Any, Final, Literal, cast, overload import litellm @@ -16,6 +16,15 @@ from litellm.utils import supports_reasoning from ...openai.chat.gpt_transformation import OpenAIGPTConfig +def _reasoning_effort_string(value: object) -> str | None: + """The /v1/messages and /v1/responses bridges wrap the level as {"effort", "summary"} for + providers with a reasoning-summary surface. Moonshot's API takes only the bare string and 400s + on an object, so the level is unwrapped and the summary, which has no Moonshot equivalent, is + dropped.""" + effort: Final = value.get("effort") if isinstance(value, Mapping) else value + return effort if isinstance(effort, str) else None + + class MoonshotChatConfig(OpenAIGPTConfig): @overload def _transform_messages( @@ -93,20 +102,18 @@ class MoonshotChatConfig(OpenAIGPTConfig): - functions parameter is not supported (use tools instead) - tool_choice doesn't support "required" value - kimi-thinking-preview doesn't support tool calls at all + + A reasoning model additionally takes `reasoning_effort`, which the OpenAI base list this + subtracts from does not carry, so it has to be added back rather than merely kept. """ - excluded_params: Final[list[str]] = ["functions"] - - # kimi-thinking-preview has additional limitations - if "kimi-thinking-preview" in model: - excluded_params.extend(["tools", "tool_choice"]) - + excluded_params: Final = frozenset( + ("functions", "tools", "tool_choice") if "kimi-thinking-preview" in model else ("functions",) + ) base_openai_params: Final = super().get_supported_openai_params(model=model) - final_params: Final[list[str]] = [] - for param in base_openai_params: - if param not in excluded_params: - final_params.append(param) - - return final_params + supported: Final = [param for param in base_openai_params if param not in excluded_params] + if supports_reasoning(model=model, custom_llm_provider="moonshot"): + return [*supported, "reasoning_effort"] + return supported def map_openai_params( self, @@ -126,7 +133,12 @@ class MoonshotChatConfig(OpenAIGPTConfig): for param, value in non_default_params.items(): if param == "max_completion_tokens": optional_params["max_tokens"] = value - elif param in supported_openai_params: + elif param not in supported_openai_params: + continue + elif param == "reasoning_effort": + if (effort := _reasoning_effort_string(value)) is not None: + optional_params["reasoning_effort"] = effort + else: optional_params[param] = value ########################################## diff --git a/litellm/llms/together_ai/chat/transformation.py b/litellm/llms/together_ai/chat/transformation.py index 47a11230cb1..449cd3ecbc5 100644 --- a/litellm/llms/together_ai/chat/transformation.py +++ b/litellm/llms/together_ai/chat/transformation.py @@ -18,6 +18,7 @@ from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger from litellm.exceptions import UnsupportedParamsError +from litellm.router_utils.reasoning_effort_capability import declared_reasoning_efforts_for_model from litellm.types.llms.openai import AllMessageValues from litellm.utils import supports_function_calling, supports_reasoning, supports_response_schema @@ -139,6 +140,8 @@ def _reasoning_effort_payload(effort: str, model: str) -> Mapping[str, object]: if effort == "none": disable_reasoning: Final[TogetherReasoningToggle] = {"enabled": False} return MappingProxyType({"reasoning": disable_reasoning}) + if effort in (declared_reasoning_efforts_for_model(model, "together_ai") or ()): + return MappingProxyType({"reasoning_effort": effort}) if model.startswith(HIGH_MAX_EFFORT_MODEL_PREFIX): return MappingProxyType({"reasoning_effort": HIGH_MAX_EFFORT_TRANSLATION.get(effort, effort)}) return MappingProxyType({"reasoning_effort": EFFORT_TRANSLATION.get(effort, effort)}) diff --git a/litellm/router_utils/reasoning_effort_capability.py b/litellm/router_utils/reasoning_effort_capability.py index 08feb96e36a..2a4fae4109f 100644 --- a/litellm/router_utils/reasoning_effort_capability.py +++ b/litellm/router_utils/reasoning_effort_capability.py @@ -87,6 +87,22 @@ def declared_reasoning_efforts(model_info: Mapping[str, object]) -> tuple[str, . return tuple(effort for effort in REASONING_EFFORT_ADVERTISEMENT_ORDER if effort in declared) +def declared_reasoning_efforts_for_model(model: str, custom_llm_provider: str) -> tuple[str, ...] | None: + """The levels an entry declares, resolved from the model string a provider config holds rather + than from a router deployment's model_info. + + None means the map has no opinion, either because the entry declares nothing or because it + describes no such model, so a caller keeps whatever it did before the entry was described. The + entry is read straight off the map rather than through get_model_info, which raises for a model + it does not know: a provider config runs on the request path for every model it serves, most of + which the map never named, and a lookup miss there must not fail the call. + """ + entry: Final = litellm.model_cost.get(f"{custom_llm_provider}/{model}") or litellm.model_cost.get(model) + if not isinstance(entry, dict): + return None + return declared_reasoning_efforts(entry) + + 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 diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py index 50f476eaaaa..8c8bea00dea 100644 --- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py @@ -769,3 +769,62 @@ class TestMoonshotResponseSchemaSupport: def test_supports_response_schema_utility_reports_true(self, model_cost_map, monkeypatch): monkeypatch.setattr(litellm, "model_cost", model_cost_map) assert litellm.utils.supports_response_schema(model="moonshot/kimi-k2.5") is True + + +class TestMoonshotReasoningEffort: + """Moonshot documents reasoning_effort as a top-level chat completions field for its reasoning + models, defaulting to max, but the OpenAI base list this config subtracts from never carried it, + so an explicit level raised UnsupportedParamsError before it reached the wire.""" + + @pytest.fixture(autouse=True) + def force_local_model_cost(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", GetModelCostMap.load_local_model_cost_map()) + + @pytest.mark.parametrize("model", ["kimi-k3", "kimi-k2.5", "kimi-k2.6", "kimi-k2-thinking"]) + def test_reasoning_model_supports_reasoning_effort(self, model): + assert "reasoning_effort" in MoonshotChatConfig().get_supported_openai_params(model) + + @pytest.mark.parametrize("model", ["moonshot-v1-8k", "kimi-latest", "kimi-k2-turbo-preview"]) + def test_non_reasoning_model_does_not_support_reasoning_effort(self, model): + assert "reasoning_effort" not in MoonshotChatConfig().get_supported_openai_params(model) + + @pytest.mark.parametrize("effort", ["low", "high", "max"]) + def test_declared_effort_reaches_optional_params(self, effort): + optional_params = litellm.get_optional_params( + model="kimi-k3", + custom_llm_provider="moonshot", + reasoning_effort=effort, + drop_params=False, + ) + + assert optional_params["reasoning_effort"] == effort + + def test_non_reasoning_model_still_rejects_reasoning_effort(self): + with pytest.raises(litellm.UnsupportedParamsError): + litellm.get_optional_params( + model="moonshot-v1-8k", + custom_llm_provider="moonshot", + reasoning_effort="high", + drop_params=False, + ) + + def test_bridge_effort_dict_is_unwrapped_to_the_level_string(self): + optional_params = MoonshotChatConfig().map_openai_params( + non_default_params={"reasoning_effort": {"effort": "high", "summary": "detailed"}}, + optional_params={}, + model="kimi-k3", + drop_params=False, + ) + + assert optional_params["reasoning_effort"] == "high" + + @pytest.mark.parametrize("value", [{"summary": "detailed"}, {"effort": 3}, 7]) + def test_effort_without_a_level_string_is_omitted(self, value): + optional_params = MoonshotChatConfig().map_openai_params( + non_default_params={"reasoning_effort": value}, + optional_params={}, + model="kimi-k3", + drop_params=False, + ) + + assert "reasoning_effort" not in optional_params diff --git a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py index 6b803578067..7eb7dc41d4f 100644 --- a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py +++ b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py @@ -1069,3 +1069,42 @@ def test_anthropic_messages_streams_together_tool_call_as_input_json_delta(): ) assert thinking_text == "Need weather and time." assert [event["delta"]["stop_reason"] for event in events if event["type"] == "message_delta"] == ["tool_use"] + + +DECLARED_LEVELS_MODEL = "moonshotai/Kimi-K3" + + +@pytest.mark.parametrize("effort", ["low", "high", "max"]) +def test_declared_level_is_sent_unchanged(effort): + """Kimi K3 declares low, high and max in the model map and Together accepts all three, but the + per-model clamp below only spares deepseek-ai/DeepSeek-V4-Pro, so max used to arrive as high and + the caller silently lost half the reasoning budget they asked for.""" + mapped = _map_reasoning_effort(DECLARED_LEVELS_MODEL, effort) + + assert mapped["reasoning_effort"] == effort + + +@pytest.mark.parametrize("effort, expected", [("minimal", "low"), ("medium", "medium"), ("xhigh", "high")]) +def test_undeclared_level_still_uses_the_clamp(effort, expected): + """The declared set is not a licence to widen: a level the entry does not name keeps whatever + the hardcoded table did for it.""" + mapped = _map_reasoning_effort(DECLARED_LEVELS_MODEL, effort) + + assert mapped["reasoning_effort"] == expected + + +def test_declared_levels_model_still_disables_reasoning_on_none(): + mapped = _map_reasoning_effort(DECLARED_LEVELS_MODEL, "none") + + assert mapped["reasoning"] == {"enabled": False} + assert "reasoning_effort" not in mapped + + +def test_get_optional_params_preserves_max_for_declared_levels_model(): + optional_params = litellm.get_optional_params( + model=DECLARED_LEVELS_MODEL, + custom_llm_provider="together_ai", + reasoning_effort="max", + ) + + assert optional_params["reasoning_effort"] == "max"