fix(moonshot, together_ai): send the reasoning effort Kimi K3 accepts (#38611)

* fix(moonshot, together_ai): send the reasoning effort Kimi K3 accepts

Moonshot documents reasoning_effort as a top-level chat completions field for its reasoning
models, and defaults it to max, but MoonshotChatConfig builds its supported params by
subtracting from the OpenAI base list, which never carried that param. An explicit level
raised UnsupportedParamsError before the request left the proxy, so low and high were
unreachable and every call ran at the provider default

Together accepts low, high and max on Kimi K3. The per-model clamp added for the gpt-oss
family folds max down to high for every model except deepseek-ai/DeepSeek-V4-Pro, so a caller
asking for max silently got roughly half the reasoning budget they paid for

Moonshot now offers reasoning_effort whenever the registry says the model reasons. Together
sends a level the map entry declares unchanged, and keeps its existing table for every level
an entry does not name, so the only value that moves is Kimi K3 at max

* fix(moonshot): unwrap the bridges' effort object to the level string
This commit is contained in:
tin-berri 2026-08-27 20:46:46 -07:00 committed by GitHub
parent 3002994c0e
commit 3300fc3a96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 143 additions and 14 deletions

View file

@ -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
##########################################

View file

@ -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)})

View file

@ -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

View file

@ -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

View file

@ -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"