diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index e5cac876..457bf4a2 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -387,14 +387,37 @@ class OpenAICompatProvider(LLMProvider): kwargs.update(overrides) break - if reasoning_effort: - kwargs["reasoning_effort"] = reasoning_effort + # Semantic vs. wire distinction for reasoning_effort. + # - semantic_effort is nanobot's internal canonical form (OpenAI's + # vocabulary: "minimal" / "low" / "medium" / "high"). It drives + # decisions like whether to disable provider thinking modes. + # - wire_effort is what we actually serialize to the provider; some + # providers (notably DashScope) reject "minimal" and require + # "minimum" instead. We accept either spelling on input and + # always compare on the semantic form so a user who configured + # "minimum" (DashScope's native spelling) still gets thinking + # disabled instead of accidentally enabled. + semantic_effort: str | None = None + if isinstance(reasoning_effort, str): + semantic_effort = reasoning_effort.lower() + if semantic_effort == "minimum": + semantic_effort = "minimal" + + wire_effort = reasoning_effort + if spec and spec.name == "dashscope" and semantic_effort == "minimal": + # DashScope's reasoning_effort.effort enum accepts: none / + # minimum / low / medium / high / xhigh. Literal "minimal" + # returns 400 invalid_value; translate on the outbound side. + wire_effort = "minimum" + + if wire_effort: + kwargs["reasoning_effort"] = wire_effort # 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: - thinking_enabled = reasoning_effort.lower() != "minimal" + thinking_enabled = semantic_effort != "minimal" extra: dict[str, Any] | None = None if spec.name == "dashscope": extra = {"enable_thinking": thinking_enabled} @@ -415,7 +438,7 @@ class OpenAICompatProvider(LLMProvider): # 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" + thinking_enabled = semantic_effort != "minimal" kwargs.setdefault("extra_body", {}).update( {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} ) diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index a12aa9fb..5067f094 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -731,10 +731,32 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None: def test_dashscope_thinking_disabled_for_minimal() -> None: + """OpenAI-style 'minimal' → DashScope wire value 'minimum' + thinking off. + DashScope rejects the literal string 'minimal' (invalid_value), so we + must translate on the outbound side while still honouring the 'no + thinking' intent via extra_body.""" kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal") + assert kw["reasoning_effort"] == "minimum" assert kw["extra_body"] == {"enable_thinking": False} +def test_dashscope_thinking_disabled_for_minimum_alias() -> None: + """Users who read DashScope docs may configure the native 'minimum' + spelling. Internally it's the same semantic as 'minimal' → thinking + must still be disabled (not enabled just because the string isn't + literally 'minimal').""" + kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimum") + assert kw["reasoning_effort"] == "minimum" + assert kw["extra_body"] == {"enable_thinking": False} + + +def test_non_dashscope_minimal_not_retranslated() -> None: + """The DashScope-specific translation must not leak to other providers; + OpenAI / Anthropic / etc. speak 'minimal' natively.""" + kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal") + assert kw["reasoning_effort"] == "minimal" + + def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None: kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None) assert "extra_body" not in kw