From fa85314052ed3676fb62fa435edf55d2c9a5e3f2 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Mon, 20 Apr 2026 06:36:47 +0000 Subject: [PATCH] fix(agent): scope think=False to Ollama endpoints only (#11237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The think parameter is Ollama-native and was being sent to ALL custom providers (Mistral, Fireworks, Together.ai, vLLM, LM Studio) when reasoning was disabled, causing HTTP 422 rejections from providers that validate request bodies. Root cause: _build_api_kwargs() used provider == 'custom' as the guard condition, matching every custom endpoint regardless of whether it understands the think parameter. Fix: - Add _is_ollama() detection method using two signals: 1. _ollama_num_ctx — set via config or /api/show probe (proven Ollama) 2. URL heuristics — 'ollama' or ':11434' in base URL (fallback) - Replace provider == 'custom' guard with _is_ollama() in the think=False section of _build_api_kwargs() Tests: 23 new tests covering: - 7 _is_ollama() unit tests (num_ctx probe, URL patterns, rejections) - 5 cloud custom provider no-think tests (Mistral, Fireworks, Together, vLLM) - 1 LM Studio no-think test (local but not Ollama) - 7 Ollama think=False positive tests (URL, num_ctx, effort, enabled) - 3 unaffected provider tests (OpenRouter, Anthropic) All tests fail without the fix (6 failures from cloud/LM Studio tests) and pass with it. 107 total tests pass (23 new + 84 existing). --- run_agent.py | 23 +- .../test_think_param_ollama_scope.py | 272 ++++++++++++++++++ 2 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 tests/run_agent/test_think_param_ollama_scope.py diff --git a/run_agent.py b/run_agent.py index 16e6038967eaf..b05ac83afbf8f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7004,6 +7004,20 @@ def _is_qwen_portal(self) -> bool: """Return True when the base URL targets Qwen Portal.""" return "portal.qwen.ai" in self._base_url_lower + def _is_ollama(self) -> bool: + """Return True when the endpoint is an Ollama server. + + Uses two signals: + 1. ``_ollama_num_ctx`` — set during init via config or /api/show probe, + which proves we successfully talked to an Ollama API. + 2. URL heuristics — ``ollama`` or the default ``:11434`` port in the + base URL, covering cases where the probe failed but the URL is + unmistakably Ollama. + """ + if self._ollama_num_ctx is not None: + return True + return "ollama" in self._base_url_lower or ":11434" in self._base_url_lower + def _qwen_prepare_chat_messages(self, api_messages: list) -> list: prepared = copy.deepcopy(api_messages) if not prepared: @@ -7371,13 +7385,16 @@ def _build_api_kwargs(self, api_messages: list) -> dict: options["num_ctx"] = self._ollama_num_ctx extra_body["options"] = options - # Ollama / custom provider: pass think=false when reasoning is disabled. + # Ollama: pass think=false when reasoning is disabled. # Ollama does not recognise the OpenRouter-style `reasoning` extra_body # field, so we use its native `think` parameter instead. # This prevents thinking-capable models (Qwen3, etc.) from generating - # blocks and producing empty-response errors when the user has + # blocks and producing empty-response errors when the user has # set reasoning_effort: none. - if self.provider == "custom" and self.reasoning_config and isinstance(self.reasoning_config, dict): + # NOTE: Only send to detected Ollama endpoints — the `think` parameter + # is Ollama-native and is rejected (HTTP 422) by other custom providers + # such as Mistral, Fireworks, Together.ai, and vLLM. + if self._is_ollama() and self.reasoning_config and isinstance(self.reasoning_config, dict): _effort = (self.reasoning_config.get("effort") or "").strip().lower() _enabled = self.reasoning_config.get("enabled", True) if _effort == "none" or _enabled is False: diff --git a/tests/run_agent/test_think_param_ollama_scope.py b/tests/run_agent/test_think_param_ollama_scope.py new file mode 100644 index 0000000000000..535e3f2a3388f --- /dev/null +++ b/tests/run_agent/test_think_param_ollama_scope.py @@ -0,0 +1,272 @@ +"""Tests for Ollama-specific `think=False` parameter scoping (issue #11237). + +The `think` parameter is Ollama-native. Before the fix it was sent to ALL +``provider == "custom"`` endpoints, causing HTTP 422 rejections from providers +like Mistral, Fireworks, Together.ai, and vLLM. + +These tests verify: + 1. ``think=False`` is NOT sent to cloud custom providers (the bug). + 2. ``think=False`` IS sent to Ollama endpoints (preserving correct behavior). + 3. ``_is_ollama()`` detection works via both num_ctx probe and URL heuristics. +""" + +import types +from unittest.mock import patch, MagicMock + +import pytest + +import sys +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +from run_agent import AIAgent + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _tool_defs(*names): + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +class _FakeOpenAI: + def __init__(self, **kw): + self.api_key = kw.get("api_key", "test") + self.base_url = kw.get("base_url", "http://test") + + def close(self): + pass + + +def _make_agent(monkeypatch, provider, base_url, model=None, reasoning_config=None): + """Create a minimal AIAgent suitable for testing _build_api_kwargs.""" + monkeypatch.setattr("run_agent.get_tool_definitions", lambda **kw: _tool_defs("web_search")) + monkeypatch.setattr("run_agent.check_toolset_requirements", lambda: {}) + monkeypatch.setattr("run_agent.OpenAI", _FakeOpenAI) + kwargs = dict( + api_key="test-key", + base_url=base_url, + provider=provider, + api_mode="chat_completions", + max_iterations=4, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + if model: + kwargs["model"] = model + agent = AIAgent(**kwargs) + if reasoning_config is not None: + agent.reasoning_config = reasoning_config + return agent + + +def _get_think_from_kwargs(agent, messages=None): + """Build API kwargs and return the value of extra_body['think'] or sentinel.""" + if messages is None: + messages = [{"role": "user", "content": "hello"}] + kwargs = agent._build_api_kwargs(messages) + extra = kwargs.get("extra_body", {}) + return extra.get("think", "") + + +# ── _is_ollama() unit tests ───────────────────────────────────────────────── + + +class TestIsOllama: + """Unit tests for the _is_ollama() detection method.""" + + def test_detects_via_num_ctx_probe(self, monkeypatch): + """If _ollama_num_ctx was detected (via /api/show), endpoint is Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:11434/v1" + ) + agent._ollama_num_ctx = 32768 + assert agent._is_ollama() is True + + def test_detects_via_ollama_in_url(self, monkeypatch): + """URL containing 'ollama' is detected even without num_ctx probe.""" + agent = _make_agent( + monkeypatch, "custom", "http://my-ollama-server.local/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is True + + def test_detects_via_port_11434(self, monkeypatch): + """Default Ollama port :11434 is detected.""" + agent = _make_agent( + monkeypatch, "custom", "http://192.168.1.100:11434/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is True + + def test_rejects_cloud_custom_provider(self, monkeypatch): + """Cloud custom provider (Mistral) is not detected as Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "https://api.mistral.ai/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is False + + def test_rejects_lm_studio(self, monkeypatch): + """Local LM Studio (port 1234) is not detected as Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:1234/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is False + + def test_rejects_vllm_remote(self, monkeypatch): + """Remote vLLM server is not detected as Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "https://vllm.mycompany.com/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is False + + def test_rejects_openrouter(self, monkeypatch): + """OpenRouter is never detected as Ollama.""" + agent = _make_agent( + monkeypatch, "openrouter", "https://openrouter.ai/api/v1" + ) + agent._ollama_num_ctx = None + assert agent._is_ollama() is False + + +# ── think=False scoping tests ──────────────────────────────────────────────── + + +class TestThinkParamOllamaOnly: + """Verify think=False is scoped to Ollama endpoints only.""" + + # ── Cloud custom providers: think must NOT be sent ── + + @pytest.mark.parametrize("base_url", [ + "https://api.mistral.ai/v1", + "https://api.fireworks.ai/inference/v1", + "https://api.together.xyz/v1", + "https://vllm.example.com/v1", + "https://my-custom-llm.company.com/openai/v1", + ]) + def test_cloud_custom_provider_no_think(self, monkeypatch, base_url): + """think=False must NOT be sent to cloud custom providers. + + This is the core regression test for issue #11237. + Before the fix, provider=="custom" caused think=False to be sent + to all custom endpoints, triggering HTTP 422 from strict providers. + """ + agent = _make_agent( + monkeypatch, "custom", base_url, + reasoning_config={"effort": "none", "enabled": False}, + ) + agent._ollama_num_ctx = None + assert _get_think_from_kwargs(agent) == "" + + def test_local_lm_studio_no_think(self, monkeypatch): + """LM Studio on localhost should not receive think=False. + + LM Studio is a local provider but NOT Ollama — it does not + understand the `think` parameter. + """ + agent = _make_agent( + monkeypatch, "custom", "http://localhost:1234/v1", + reasoning_config={"effort": "none", "enabled": False}, + ) + agent._ollama_num_ctx = None + assert _get_think_from_kwargs(agent) == "" + + # ── Ollama endpoints: think must be sent ── + + @pytest.mark.parametrize("base_url", [ + "http://localhost:11434/v1", + "http://ollama.local:11434/v1", + "http://192.168.1.50:11434/v1", + ]) + def test_ollama_url_sends_think_false(self, monkeypatch, base_url): + """Ollama URLs (with :11434) should still receive think=False.""" + agent = _make_agent( + monkeypatch, "custom", base_url, + reasoning_config={"effort": "none", "enabled": False}, + ) + # num_ctx may not be detected yet but URL is clear + agent._ollama_num_ctx = None + assert _get_think_from_kwargs(agent) is False + + def test_ollama_num_ctx_probe_sends_think_false(self, monkeypatch): + """When Ollama is detected via num_ctx probe, think=False is sent. + + This covers the case where the URL might not contain 'ollama' or + :11434 (e.g. behind a reverse proxy) but the probe succeeded. + """ + agent = _make_agent( + monkeypatch, "custom", "http://llm-proxy.internal/v1", + reasoning_config={"effort": "none", "enabled": False}, + ) + agent._ollama_num_ctx = 32768 + assert _get_think_from_kwargs(agent) is False + + def test_ollama_effort_none_sends_think_false(self, monkeypatch): + """reasoning_effort='none' triggers think=False on Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:11434/v1", + reasoning_config={"effort": "none", "enabled": True}, + ) + agent._ollama_num_ctx = 8192 + assert _get_think_from_kwargs(agent) is False + + def test_ollama_enabled_false_sends_think_false(self, monkeypatch): + """reasoning enabled=False triggers think=False on Ollama.""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:11434/v1", + reasoning_config={"effort": "medium", "enabled": False}, + ) + agent._ollama_num_ctx = 8192 + assert _get_think_from_kwargs(agent) is False + + def test_ollama_reasoning_enabled_no_think(self, monkeypatch): + """When reasoning is enabled, think is NOT added (Ollama handles it).""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:11434/v1", + reasoning_config={"effort": "medium", "enabled": True}, + ) + agent._ollama_num_ctx = 8192 + assert _get_think_from_kwargs(agent) == "" + + def test_ollama_no_reasoning_config_no_think(self, monkeypatch): + """When there's no reasoning config at all, think is NOT added.""" + agent = _make_agent( + monkeypatch, "custom", "http://localhost:11434/v1", + ) + agent._ollama_num_ctx = 8192 + agent.reasoning_config = None + assert _get_think_from_kwargs(agent) == "" + + # ── Non-custom providers are unaffected ── + + def test_openrouter_unaffected(self, monkeypatch): + """OpenRouter path should never have 'think' in extra_body.""" + agent = _make_agent( + monkeypatch, "openrouter", "https://openrouter.ai/api/v1", + reasoning_config={"effort": "none", "enabled": False}, + ) + assert _get_think_from_kwargs(agent) == "" + + def test_anthropic_unaffected(self, monkeypatch): + """Anthropic path should never have 'think' in extra_body.""" + agent = _make_agent( + monkeypatch, "anthropic", "https://api.anthropic.com", + reasoning_config={"effort": "none", "enabled": False}, + ) + agent.api_mode = "anthropic_messages" + assert _get_think_from_kwargs(agent) == ""