mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
3002994c0e
commit
3300fc3a96
5 changed files with 143 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
##########################################
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue