fix(llm): enforce API timeout in the secondary LLM dispatch path (#1442)

_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) <noreply@anthropic.com>
This commit is contained in:
DhruvTilva
2026-06-29 09:45:03 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 86ecb769b6
commit 4e4935a64a
2 changed files with 61 additions and 2 deletions
+2 -2
View File
@@ -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}],
+59
View File
@@ -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