From 4e4935a64a3f395a30d5f19be31700dbdd829835 Mon Sep 17 00:00:00 2001 From: DhruvTilva Date: Mon, 29 Jun 2026 09:45:03 +0100 Subject: [PATCH] fix(llm): enforce API timeout in the secondary LLM dispatch path (#1442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _call_llm (used by the dedup LLM tiebreaker) built its Anthropic and OpenAI-compatible clients with max_retries but no timeout, so requests on this path silently ignored GRAPHIFY_API_TIMEOUT — unlike the primary extraction paths (_call_openai_compat / _call_claude) which already pass both. Add timeout=_resolve_api_timeout() to both constructors. The PR branch self-neutralized: a v8 merge resolved the conflict in favor of the max_retries-bearing line and dropped the original one-line fix, so it is re-applied here on top of current v8 with max_retries preserved. Adds regression coverage for both _call_llm branches, which were previously untested. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/llm.py | 4 +-- tests/test_llm_backends.py | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/graphify/llm.py b/graphify/llm.py index 396f9dc1..c0d2efa2 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1905,7 +1905,7 @@ def _call_llm( import anthropic except ImportError as exc: raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc - client = anthropic.Anthropic(api_key=key, base_url=cfg["base_url"], max_retries=_resolve_max_retries()) + client = anthropic.Anthropic(api_key=key, base_url=cfg["base_url"], timeout=_resolve_api_timeout(), max_retries=_resolve_max_retries()) resp = client.messages.create( model=mdl, max_tokens=max_tokens, @@ -1988,7 +1988,7 @@ def _call_llm( from openai import OpenAI except ImportError as exc: raise ImportError(_backend_pkg_hint("openai", "openai")) from exc - client = OpenAI(api_key=key, base_url=cfg["base_url"], max_retries=_resolve_max_retries()) + client = OpenAI(api_key=key, base_url=cfg["base_url"], timeout=_resolve_api_timeout(), max_retries=_resolve_max_retries()) kwargs: dict = { "model": mdl, "messages": [{"role": "user", "content": prompt}], diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index acb04e95..79f64027 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -1028,3 +1028,62 @@ def test_openai_compat_client_built_with_retries(monkeypatch): "user msg", temperature=0, max_completion_tokens=4096, backend="kimi", ) assert ctor_kwargs.get("max_retries", 0) >= 5, ctor_kwargs + + +def test_call_llm_claude_client_built_with_timeout_and_retries(monkeypatch): + """The secondary dispatch path (_call_llm, used by the dedup tiebreaker) + must build its Anthropic client with both timeout and max_retries, matching + the primary extraction path — #1442. Previously _call_llm passed neither + (then only max_retries), so GRAPHIFY_API_TIMEOUT was silently ignored here.""" + import sys + import types + + ctor_kwargs = {} + + class _FakeMessages: + def create(self, **_): + return types.SimpleNamespace(content=[types.SimpleNamespace(text="ok")]) + + class _FakeAnthropic: + def __init__(self, *_, **kwargs): + ctor_kwargs.update(kwargs) + self.messages = _FakeMessages() + + fake_module = types.ModuleType("anthropic") + fake_module.Anthropic = _FakeAnthropic + monkeypatch.setitem(sys.modules, "anthropic", fake_module) + monkeypatch.setattr(llm, "_get_backend_api_key", lambda _b: "fake-key") + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "1") + monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False) + + assert llm._call_llm("hi", backend="claude") == "ok" + assert ctor_kwargs.get("timeout") == 1.0, ctor_kwargs + assert ctor_kwargs.get("max_retries", 0) >= 5, ctor_kwargs + + +def test_call_llm_openai_compat_client_built_with_timeout_and_retries(monkeypatch): + """Same #1442 fix for the OpenAI-compatible branch of _call_llm.""" + import sys + import types + + ctor_kwargs = {} + + class _FakeOpenAI: + def __init__(self, *_, **kwargs): + ctor_kwargs.update(kwargs) + self.chat = self + self.completions = self + + def create(self, **_): + return _fake_openai_response("ok", finish_reason="stop", completion_tokens=1) + + fake_module = types.ModuleType("openai") + fake_module.OpenAI = _FakeOpenAI + monkeypatch.setitem(sys.modules, "openai", fake_module) + monkeypatch.setattr(llm, "_get_backend_api_key", lambda _b: "fake-key") + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "1") + monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False) + + llm._call_llm("hi", backend="kimi") + assert ctor_kwargs.get("timeout") == 1.0, ctor_kwargs + assert ctor_kwargs.get("max_retries", 0) >= 5, ctor_kwargs