Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions plugins/model-providers/custom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
Ollama instances and OpenAI-compatible reasoning endpoints (GLM-5.2 on
Volcengine ARK, vLLM, llama.cpp). Key quirks:
- ollama_num_ctx → extra_body.options.num_ctx (local context window)
- Enabling reasoning is gated on ``supports_reasoning`` (the
transport-resolved per-model capability, e.g. Ollama's /api/show
"thinking" flag): a model that doesn't declare thinking support never
receives an effort value, because Ollama's /v1/chat/completions 400s
with ``"<model>" does not support thinking``. Turning reasoning OFF is
ungated — non-thinking models accept it with 200.
- reasoning_config disabled → top-level reasoning_effort="none"
(Ollama /v1/chat/completions ignores think=False — ollama#14820)
+ extra_body.think = False for /api/chat and proxies
Expand All @@ -26,12 +32,13 @@ def build_api_kwargs_extras(
*,
reasoning_config: dict | None = None,
ollama_num_ctx: int | None = None,
supports_reasoning: bool = False,
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}

# Ollama context window
# Ollama context window — independent of reasoning capability.
if ollama_num_ctx:
options = extra_body.get("options", {})
options["num_ctx"] = ollama_num_ctx
Expand All @@ -40,6 +47,28 @@ def build_api_kwargs_extras(
# Reasoning / thinking control for custom OpenAI-compatible endpoints
# (GLM-5.2 on Volcengine ARK, vLLM, Ollama, llama.cpp, …).
#
# Enabling reasoning is gated on ``supports_reasoning``, which the
# transport resolves per model (for Ollama-compatible routes — local
# servers included since AIAgent._supports_reasoning_extra_body's
# port-11434 fix — from the native /api/show "thinking" capability,
# mirroring the ollama-cloud profile). Without that gate a model which
# does not declare "thinking" still received an effort value and
# Ollama's /v1/chat/completions rejected the whole request with
# ``HTTP 400: "qwen2.5:7b" does not support thinking``.
#
# Only the *enable* branch is gated. Measured against Ollama
# /v1/chat/completions with qwen2.5:7b (a non-thinking model):
#
# reasoning_effort="medium" → HTTP 400 (does not support thinking)
# reasoning_effort="none" → HTTP 200
# think=false → HTTP 200
#
# Turning thinking OFF is accepted even by models that cannot think,
# so the disabled branch stays ungated: gating it would silently drop
# a user's explicit "don't reason" for any route whose capability
# probe is unavailable, leaving a thinking-capable model reasoning
# against instructions.
#
# - disabled → extra_body.think = False (Ollama's thinking-off flag)
# - enabled + effort set → TOP-LEVEL reasoning_effort string, the
# format GLM-5.2/ARK and other OpenAI-compatible reasoning APIs
Expand All @@ -63,7 +92,7 @@ def build_api_kwargs_extras(
# ignore them.
top_level["reasoning_effort"] = "none"
extra_body["think"] = False
elif _effort:
elif _effort and supports_reasoning:
top_level["reasoning_effort"] = _effort

return extra_body, top_level
Expand Down
24 changes: 19 additions & 5 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7250,11 +7250,25 @@ def _supports_reasoning_extra_body(self) -> bool:
opts = self._lmstudio_reasoning_options_cached()
# "off-only" (or absent) means no real reasoning capability.
return any(opt and opt != "off" for opt in opts)
# Ollama Cloud (and any Ollama-compatible server): the native
# /api/show capabilities list is authoritative — emit reasoning_effort
# only for models that declare the "thinking" capability. deepseek-v4
# has it; gemma3 / qwen3-coder don't. Cached per (model, base_url).
if base_url_host_matches(self._base_url_lower, "ollama.com"):
# Ollama Cloud (and any Ollama-compatible server, local included): the
# native /api/show capabilities list is authoritative — emit
# reasoning_effort only for models that declare the "thinking"
# capability. deepseek-v4 has it; gemma3 / qwen3-coder don't. Cached
# per (model, base_url).
#
# Local Ollama (``http://localhost:11434/v1`` and friends) previously
# fell through this gate to the OpenRouter-only branch below, which
# always returned False — so reasoning_effort was silently never
# emitted even for local thinking-capable models (deepseek-r1), AND
# (via the separate CustomProfile defect) it WAS emitted unguarded
# for non-thinking local models, 400ing with `"<model>" does not
# support thinking`. Port 11434 is Ollama's universal default across
# every platform/install method, so it is as reliable a signal as the
# ollama.com hostname for Ollama Cloud.
if (
base_url_host_matches(self._base_url_lower, "ollama.com")
or ":11434" in self._base_url_lower
):
return self._ollama_supports_thinking_cached()
if "openrouter" not in self._base_url_lower:
return False
Expand Down
30 changes: 30 additions & 0 deletions tests/agent/transports/test_chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,36 @@ def test_custom_think_false(self, transport):
)
assert kw["extra_body"]["think"] is False

def test_custom_disable_survives_missing_capability(self, transport):
"""Omitting supports_reasoning (defaults False) must NOT drop the
disable fields: Ollama accepts reasoning_effort="none"/think=false
with HTTP 200 even on non-thinking models, and dropping them would
silently leave a thinking-capable model reasoning against an explicit
request not to."""
from providers import get_provider_profile
profile = get_provider_profile("custom")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="qwen2.5:7b", messages=msgs,
provider_profile=profile,
reasoning_config={"effort": "none"},
)
assert kw["extra_body"]["think"] is False
assert kw["reasoning_effort"] == "none"

def test_custom_supports_reasoning_false_omits_effort(self, transport):
from providers import get_provider_profile
profile = get_provider_profile("custom")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="qwen2.5:7b", messages=msgs,
provider_profile=profile,
reasoning_config={"enabled": True, "effort": "medium"},
supports_reasoning=False,
)
assert "reasoning_effort" not in kw
assert "think" not in kw.get("extra_body", {})



def test_gemini_openai_compat_flash_reasoning_maps_to_nested_google_thinking_config(self, transport):
Expand Down
156 changes: 156 additions & 0 deletions tests/hermes_cli/test_ollama_local_reasoning_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Tests for the local-Ollama reasoning capability gate.

Before this fix, ``AIAgent._supports_reasoning_extra_body()`` only probed
Ollama's ``/api/show`` "thinking" capability for the ``ollama.com`` hostname
(Ollama Cloud). A local Ollama server (``http://localhost:11434/v1`` and
equivalents) fell through that check and hit the final
``if "openrouter" not in self._base_url_lower: return False`` — so the gate
never probed at all and always reported "no reasoning support", regardless of
what the local model actually declared.

Combined with the separate ``CustomProfile.build_api_kwargs_extras`` defect
(it never consulted ``supports_reasoning`` and emitted ``reasoning_effort``
unconditionally — covered in
``tests/plugins/model_providers/test_custom_profile.py``), a profile whose
``fallback_providers`` pointed at local Ollama running a non-thinking model
(e.g. ``qwen2.5:7b``) failed with::

HTTP 400: "qwen2.5:7b" does not support thinking

Fixing only the ``CustomProfile`` side would have been a regression on its
own: since the gate always returned False for localhost, a local
thinking-capable model (e.g. ``deepseek-r1``) would stop receiving
``reasoning_effort`` entirely. Both fixes are required together.

The gate reads exactly one attribute off ``self`` (``_base_url_lower``) plus
two cached probe helpers, so these tests bind the unbound method to a stub
instead of constructing a real ``AIAgent`` — no provider configuration, no
network, no credentials.
"""

from __future__ import annotations

from types import SimpleNamespace

import pytest


def _gate(base_url: str, *, probe_result: bool = False, record: list | None = None):
"""Invoke ``_supports_reasoning_extra_body`` against a minimal stub.

``record`` collects a marker whenever the Ollama probe is consulted, which
is what proves the local branch is reached at all (it previously wasn't).
"""
from run_agent import AIAgent

def _probe():
if record is not None:
record.append(base_url)
return probe_result

stub = SimpleNamespace(
_base_url_lower=base_url.lower(),
provider="custom",
model="test-model",
_ollama_supports_thinking_cached=_probe,
_lmstudio_reasoning_options_cached=lambda: [],
)
return AIAgent._supports_reasoning_extra_body(stub)


LOCAL_URLS = [
"http://localhost:11434/v1",
"http://127.0.0.1:11434/v1",
"http://localhost:11434",
"http://192.168.1.50:11434/v1",
]


class TestLocalOllamaReasoningGate:
"""Local Ollama is probed exactly like Ollama Cloud."""

@pytest.mark.parametrize("base_url", LOCAL_URLS)
def test_model_without_thinking_suppresses_reasoning(self, base_url):
"""qwen2.5:7b-style model: probe says no → gate says no."""
seen: list = []
assert _gate(base_url, probe_result=False, record=seen) is False
assert seen == [base_url], "the Ollama probe must actually be consulted"

@pytest.mark.parametrize("base_url", LOCAL_URLS)
def test_model_with_thinking_allows_reasoning(self, base_url):
"""Non-regression: deepseek-r1-style model still gets reasoning.

This is the half that a CustomProfile-only fix would have broken.
"""
seen: list = []
assert _gate(base_url, probe_result=True, record=seen) is True
assert seen == [base_url]

def test_ollama_cloud_still_probed(self):
"""The pre-existing ollama.com behaviour is untouched."""
seen: list = []
assert _gate("https://ollama.com/v1", probe_result=True, record=seen) is True
assert seen == ["https://ollama.com/v1"]

def test_non_ollama_local_server_is_not_probed(self):
"""The check is Ollama-specific, not "any local endpoint".

A local vLLM/llama.cpp server on another port must keep falling
through to the existing OpenRouter-only branch, so this fix cannot
change behaviour for endpoints it knows nothing about.
"""
seen: list = []
assert _gate("http://localhost:8000/v1", probe_result=True, record=seen) is False
assert seen == [], "a non-Ollama port must not trigger the Ollama probe"

def test_openrouter_branch_unaffected(self):
"""Sanity: the gate still refuses unknown non-OpenRouter hosts."""
assert _gate("https://api.example.com/v1", probe_result=True) is False


class TestSupportsReasoningReachesCustomProfile:
"""The resolved capability actually reaches the profile.

``CustomProfile.build_api_kwargs_extras`` defaults ``supports_reasoning``
to False (fail closed). That default is only safe because every real call
site passes the transport-resolved value explicitly — this pins it.
"""

def test_transport_passes_supports_reasoning_to_profile(self):
import inspect

from agent.transports import chat_completions

src = inspect.getsource(chat_completions)
assert "supports_reasoning=params.get(" in src, (
"the transport must forward the resolved capability to the profile; "
"without it CustomProfile's fail-closed default would silently "
"disable reasoning for every custom endpoint"
)

def test_non_thinking_model_gets_no_effort(self):
"""End-to-end on the profile: capability False → no effort emitted."""
import model_tools # noqa: F401 (triggers plugin discovery)
import providers

profile = providers.get_provider_profile("custom")
eb, tl = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "medium"},
supports_reasoning=False,
model="qwen2.5:7b",
)
assert "reasoning_effort" not in tl
assert "think" not in eb

def test_thinking_model_still_gets_effort(self):
"""End-to-end on the profile: capability True → effort emitted."""
import model_tools # noqa: F401
import providers

profile = providers.get_provider_profile("custom")
_, tl = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model="deepseek-r1",
)
assert tl == {"reasoning_effort": "high"}
Loading