From 9e2278826fa94ec754532ef04837e56781c2bb43 Mon Sep 17 00:00:00 2001 From: razzh Date: Tue, 14 Apr 2026 11:54:39 +0800 Subject: [PATCH] feat(provider): enable Kimi thinking via extra_body for k2.5 and k2.6 - Inject `thinking={"type": "enabled|disabled"}` via extra_body for Kimi thinking-capable models (kimi-k2.5, k2.6-code-preview). - Add _is_kimi_thinking_model helper to handle both bare slugs and OpenRouter-style prefixed names (e.g. moonshotai/kimi-k2.5). - reasoning_effort="minimal" maps to disabled; any other value enables it. - Add tests for enabled/disabled states and OpenRouter prefix handling. --- nanobot/providers/openai_compat_provider.py | 33 +++++++++++++++ tests/providers/test_litellm_kwargs.py | 47 +++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index bf82e538..1a9f295a 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -50,6 +50,29 @@ _DEFAULT_OPENROUTER_HEADERS = { "X-OpenRouter-Title": "nanobot", "X-OpenRouter-Categories": "cli-agent,personal-agent", } +_KIMI_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "k2.6-code-preview", +}) + + +def _is_kimi_thinking_model(model_name: str) -> bool: + """Return True if model_name refers to a Kimi thinking-capable model. + + Supports two forms: + - Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS + - Slug match: moonshotai/kimi-k2.5 -> the part after the last "/" + is checked against _KIMI_THINKING_MODELS + + This covers both the native Moonshot provider (bare slug) and + OpenRouter-style names (``"publisher/slug"``). + """ + name = model_name.lower() + if name in _KIMI_THINKING_MODELS: + return True + if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS: + return True + return False def _short_tool_id() -> str: @@ -363,6 +386,16 @@ class OpenAICompatProvider(LLMProvider): if extra: kwargs.setdefault("extra_body", {}).update(extra) + # Model-level thinking injection for Kimi thinking-capable models. + # Strip any provider prefix (e.g. "moonshotai/") before the set lookup + # so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled + # identically to bare names like "kimi-k2.5". + if reasoning_effort is not None and _is_kimi_thinking_model(model_name): + thinking_enabled = reasoning_effort.lower() != "minimal" + kwargs.setdefault("extra_body", {}).update( + {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} + ) + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 8a1e5247..8304aae8 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -730,3 +730,50 @@ def test_openai_no_thinking_extra_body() -> None: """Non-thinking providers should never get extra_body for thinking.""" kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium") assert "extra_body" not in kw + + +def test_kimi_k25_thinking_enabled() -> None: + """kimi-k2.5 with reasoning_effort set should opt in to thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + + +def test_kimi_k25_thinking_disabled_for_minimal() -> None: + """reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + + +def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None: + """Without reasoning_effort the thinking param must not be injected.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None: + """OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.""" + kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + +def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None: + """OpenRouter names must NOT trigger thinking without reasoning_effort.""" + kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_kimi_k26_code_preview_thinking_enabled() -> None: + """k2.6-code-preview also supports thinking; should behave like k2.5.""" + kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + + +def test_kimi_k2_series_no_thinking_injection() -> None: + """kimi-k2 (non-thinking) models must NOT receive extra_body.thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2", reasoning_effort="high") + assert "extra_body" not in kw + + +def test_kimi_k2_thinking_series_no_thinking_injection() -> None: + """kimi-k2-thinking series models must NOT receive extra_body.thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high") + assert "extra_body" not in kw