diff --git a/src/agent_loop.py b/src/agent_loop.py index e12412b..d37169c 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -1450,7 +1450,7 @@ async def stream_agent_loop( except Exception as _e: logger.debug(f"endpoint supports_tools lookup failed: {_e}") _model_supports_tools = any(kw in _model_lc for kw in ( - "deepseek", "gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma", + "gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma", "qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2", "llama-3.3", "llama-4", # Local-served models that follow OpenAI-style function calling @@ -1458,10 +1458,20 @@ async def stream_agent_loop( # with the per-endpoint flag above. "minimax", "kimi", "yi-", "phi-3", "phi-4", "command-r", "glm-4", "internlm", "hermes", + # deepseek-v2/v3/chat support tools via the cloud API; deepseek-r1 + # (reasoning model) does not — handled by the blocklist below. + "deepseek-v", "deepseek-chat", + )) + # Models known to reject tool schemas at the Ollama/local level even when + # the endpoint URL would otherwise enable native function calling. + # The per-endpoint supports_tools flag (True/False) always takes priority + # and can override this list for users who know their setup. + _model_no_tools = any(kw in _model_lc for kw in ( + "deepseek-r1", )) if _endpoint_supports is True: _is_api_model = True - elif _endpoint_supports is False: + elif _endpoint_supports is False or _model_no_tools: _is_api_model = False else: _is_api_model = any(h in endpoint_url for h in _API_HOSTS) or _model_supports_tools diff --git a/tests/test_tool_support_heuristic.py b/tests/test_tool_support_heuristic.py new file mode 100644 index 0000000..f6a8b9c --- /dev/null +++ b/tests/test_tool_support_heuristic.py @@ -0,0 +1,106 @@ +"""Regression tests for the tool-support heuristic in stream_agent_loop. + +Verifies two critical cases: + 1. deepseek-r1 on a local Ollama endpoint must NOT enable native tool schemas + (Ollama returns HTTP 400 for these models when tools are sent). + 2. api.deepseek.com must still be treated as tool-capable via the host + allow-list (_API_HOSTS), so cloud deepseek users keep working. +""" +import pytest +from src.agent_loop import _API_HOSTS + + +def _compute_is_api_model(model: str, endpoint_url: str, endpoint_supports=None) -> bool: + """Replicate the heuristic from stream_agent_loop without side effects.""" + model_lc = model.lower() + + model_supports_tools = any(kw in model_lc for kw in ( + "gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma", + "qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2", + "llama-3.3", "llama-4", + "minimax", "kimi", "yi-", "phi-3", "phi-4", "command-r", + "glm-4", "internlm", "hermes", + "deepseek-v", "deepseek-chat", + )) + model_no_tools = any(kw in model_lc for kw in ( + "deepseek-r1", + )) + + if endpoint_supports is True: + return True + if endpoint_supports is False or model_no_tools: + return False + return any(h in endpoint_url for h in _API_HOSTS) or model_supports_tools + + +class TestDeepSeekToolSupport: + # --- local Ollama cases (must NOT get tool schemas) --- + + def test_deepseek_r1_7b_local_ollama_no_tools(self): + result = _compute_is_api_model( + "deepseek-r1:7b", "http://localhost:11434/v1" + ) + assert result is False, ( + "deepseek-r1:7b on Ollama must not enable tool schemas " + "(Ollama returns HTTP 400 for this model)" + ) + + def test_deepseek_r1_14b_local_no_tools(self): + assert _compute_is_api_model("deepseek-r1:14b", "http://localhost:11434/v1") is False + + def test_deepseek_r1_70b_local_no_tools(self): + assert _compute_is_api_model("deepseek-r1:70b", "http://127.0.0.1:11434/v1") is False + + def test_deepseek_r1_via_docker_no_tools(self): + assert _compute_is_api_model( + "deepseek-r1:7b", "http://host.docker.internal:11434/v1" + ) is False + + # --- cloud API cases (must still get tool schemas) --- + + def test_deepseek_cloud_api_gets_tools(self): + result = _compute_is_api_model( + "deepseek-chat", "https://api.deepseek.com/v1" + ) + assert result is True, ( + "api.deepseek.com must be treated as tool-capable via _API_HOSTS" + ) + + def test_deepseek_v3_cloud_gets_tools(self): + assert _compute_is_api_model("deepseek-v3", "https://api.deepseek.com/v1") is True + + def test_deepseek_v2_cloud_gets_tools(self): + assert _compute_is_api_model("deepseek-v2.5", "https://api.deepseek.com/v1") is True + + # --- endpoint_supports override takes priority --- + + def test_endpoint_supports_true_overrides_blocklist(self): + """A user who explicitly sets supports_tools=True on their endpoint + can force tool schemas even for deepseek-r1 (e.g. custom server).""" + result = _compute_is_api_model( + "deepseek-r1:7b", "http://localhost:11434/v1", endpoint_supports=True + ) + assert result is True + + def test_endpoint_supports_false_overrides_cloud(self): + """supports_tools=False on an endpoint gates even cloud APIs.""" + result = _compute_is_api_model( + "deepseek-chat", "https://api.deepseek.com/v1", endpoint_supports=False + ) + assert result is False + + # --- other local models unaffected --- + + def test_qwen_local_still_gets_tools(self): + assert _compute_is_api_model("qwen2.5:14b", "http://localhost:11434/v1") is True + + def test_llama_local_gets_tools_via_host(self): + assert _compute_is_api_model("llama3.2:3b", "http://localhost:11434/v1") is True + + +class TestApiHostsContainsDeepSeek: + def test_api_deepseek_com_in_api_hosts(self): + assert "api.deepseek.com" in _API_HOSTS + + def test_deepseek_com_in_api_hosts(self): + assert "deepseek.com" in _API_HOSTS