diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index d9eb64dd..f603b9e3 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -58,6 +58,15 @@ _KIMI_THINKING_MODELS: frozenset[str] = frozenset({ "k2.6-code-preview", }) +# Maps ProviderSpec.thinking_style → extra_body builder. +# Each builder takes a bool (thinking_enabled) and returns the dict to +# merge into extra_body, keeping the style→wire-format mapping in one place. +_THINKING_STYLE_MAP: dict[str, Any] = { + "thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}}, + "enable_thinking": lambda on: {"enable_thinking": on}, + "reasoning_split": lambda on: {"reasoning_split": on}, +} + def _is_kimi_thinking_model(model_name: str) -> bool: """Return True if model_name refers to a Kimi thinking-capable model. @@ -407,20 +416,11 @@ class OpenAICompatProvider(LLMProvider): # Provider-specific thinking parameters. # Only sent when reasoning_effort is explicitly configured so that # the provider default is preserved otherwise. - if spec and reasoning_effort is not None: + # The mapping is driven by ProviderSpec.thinking_style so that adding + # a new provider never requires touching this function. + if spec and spec.thinking_style and reasoning_effort is not None: thinking_enabled = semantic_effort != "minimal" - extra: dict[str, Any] | None = None - if spec.name == "dashscope": - extra = {"enable_thinking": thinking_enabled} - elif spec.name == "minimax": - extra = {"reasoning_split": thinking_enabled} - elif spec.name in ( - "volcengine", "volcengine_coding_plan", - "byteplus", "byteplus_coding_plan", - ): - extra = { - "thinking": {"type": "enabled" if thinking_enabled else "disabled"} - } + extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled) if extra: kwargs.setdefault("extra_body", {}).update(extra) @@ -438,6 +438,26 @@ class OpenAICompatProvider(LLMProvider): kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" + # Backfill reasoning_content on legacy assistant messages. + # DeepSeek V4 (and potentially others) rejects thinking-mode + # requests that contain assistant messages without reasoning_content + # — even on turns that had no tool calls. This happens when a + # session was started with a non-thinking model or without + # reasoning_effort, then the user switches thinking mode on + # mid-session. Injecting an empty string satisfies the API + # without altering semantics (the model treats it as "no + # thinking happened on that turn"). + thinking_active = ( + (spec and spec.thinking_style and reasoning_effort is not None + and semantic_effort != "minimal") + or (reasoning_effort is not None and _is_kimi_thinking_model(model_name) + and semantic_effort != "minimal") + ) + if thinking_active: + for msg in kwargs["messages"]: + if msg.get("role") == "assistant" and "reasoning_content" not in msg: + msg["reasoning_content"] = "" + return kwargs def _should_use_responses_api( diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index f633cc83..5037e300 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -63,6 +63,14 @@ class ProviderSpec: # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching) supports_prompt_caching: bool = False + # How to inject the thinking on/off toggle into extra_body. + # "" — no extra_body needed (default) + # "thinking_type" — {"thinking": {"type": "enabled"/"disabled"}} + # (DeepSeek, VolcEngine, BytePlus) + # "enable_thinking" — {"enable_thinking": true/false} (DashScope) + # "reasoning_split" — {"reasoning_split": true/false} (MiniMax) + thinking_style: str = "" + @property def label(self) -> str: return self.display_name or self.name.title() @@ -143,6 +151,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_gateway=True, detect_by_base_keyword="volces", default_api_base="https://ark.cn-beijing.volces.com/api/v3", + thinking_style="thinking_type", ), # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine @@ -155,6 +164,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_gateway=True, default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3", strip_model_prefix=True, + thinking_style="thinking_type", ), # BytePlus: VolcEngine international, pay-per-use models @@ -168,6 +178,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( detect_by_base_keyword="bytepluses", default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3", strip_model_prefix=True, + thinking_style="thinking_type", ), # BytePlus Coding Plan: same key as byteplus @@ -180,6 +191,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_gateway=True, default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3", strip_model_prefix=True, + thinking_style="thinking_type", ), @@ -233,6 +245,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( display_name="DeepSeek", backend="openai_compat", default_api_base="https://api.deepseek.com", + thinking_style="thinking_type", ), # Gemini: Google's OpenAI-compatible endpoint ProviderSpec( @@ -261,6 +274,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( display_name="DashScope", backend="openai_compat", default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", + thinking_style="enable_thinking", ), # Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0. ProviderSpec( @@ -283,6 +297,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( display_name="MiniMax", backend="openai_compat", default_api_base="https://api.minimax.io/v1", + thinking_style="reasoning_split", ), # MiniMax Anthropic-compatible endpoint: supports thinking mode ProviderSpec( diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 41188c72..dfa0f58a 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -785,6 +785,75 @@ def test_byteplus_no_extra_body_when_reasoning_effort_none() -> None: assert "extra_body" not in kw +def test_deepseek_thinking_enabled() -> None: + """DeepSeek V4 requires extra_body.thinking when reasoning_effort is set.""" + kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="high") + assert kw["extra_body"] == {"thinking": {"type": "enabled"}} + + +def test_deepseek_thinking_disabled_for_minimal() -> None: + """reasoning_effort='minimal' must send thinking.type=disabled to DeepSeek.""" + kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="minimal") + assert kw["extra_body"] == {"thinking": {"type": "disabled"}} + + +def test_deepseek_no_extra_body_when_reasoning_effort_none() -> None: + """Without reasoning_effort the thinking param must not be injected.""" + kw = _build_kwargs_for("deepseek", "deepseek-chat", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_deepseek_backfills_reasoning_content_on_legacy_tool_call_messages() -> None: + """Session messages from before thinking mode was enabled may have assistant + messages with tool_calls but no reasoning_content. DeepSeek V4 rejects these + with 400. _build_kwargs must backfill reasoning_content='' on them.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) + messages = [ + {"role": "user", "content": "search for news"}, + {"role": "assistant", "content": "", "tool_calls": [ + {"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "tc1", "content": "result"}, + {"role": "assistant", "content": "Here are the results."}, + {"role": "user", "content": "hi"}, + ] + kw = p._build_kwargs( + messages=messages, tools=None, model="deepseek-v4-pro", + max_tokens=1024, temperature=0.7, + reasoning_effort="high", tool_choice=None, + ) + for msg in kw["messages"]: + if msg.get("role") == "assistant": + assert "reasoning_content" in msg, "legacy assistant message missing reasoning_content" + assert msg["reasoning_content"] == "" + + +def test_backfill_does_not_touch_messages_when_thinking_off() -> None: + """When reasoning_effort is None or minimal, legacy messages must NOT be altered.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "", "tool_calls": [ + {"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "tc1", "content": "result"}, + {"role": "user", "content": "thanks"}, + ] + for effort in (None, "minimal"): + kw = p._build_kwargs( + messages=list(messages), tools=None, model="deepseek-v4-pro", + max_tokens=1024, temperature=0.7, + reasoning_effort=effort, tool_choice=None, + ) + for msg in kw["messages"]: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + assert "reasoning_content" not in msg + + 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")