From 75c4de775928fbd2e01b7d0c138db7b1a9432550 Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 5 Jun 2026 10:45:01 +0100 Subject: [PATCH] honour GRAPHIFY_API_TIMEOUT in claude-cli and Anthropic SDK backends (#1112) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- graphify/llm.py | 30 +++++++++++++++------------ tests/test_claude_cli_backend.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f46a4f34..10bba253 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe | `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) | | `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag | | `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files | -| `GRAPHIFY_API_TIMEOUT` | HTTP timeout in seconds (default: 600) | optional — also `--api-timeout` flag | +| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, and Anthropic SDK backends (default: 600) | optional — also `--api-timeout` flag | | `GRAPHIFY_FORCE` | Force graph rebuild even with fewer nodes | optional — also `--force` flag | | `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` | | `GRAPHIFY_TRIAGE_BACKEND` | Backend for `graphify prs --triage` | optional — auto-detected from available keys | diff --git a/graphify/llm.py b/graphify/llm.py index 21c63721..f6cad236 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -214,6 +214,19 @@ def _resolve_max_tokens(default: int) -> int: pass return default + +def _resolve_api_timeout(default: float = 600.0) -> float: + """Honour GRAPHIFY_API_TIMEOUT env var override, else use default (seconds).""" + raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip() + if raw: + try: + v = float(raw) + if v > 0: + return v + except ValueError: + pass + return default + _EXTRACTION_SYSTEM = """\ You are a graphify semantic extraction agent. Extract a knowledge graph fragment from the files provided. Output ONLY valid JSON — no explanation, no markdown fences, no preamble. @@ -434,16 +447,7 @@ def _call_openai_compat( # default. Honour GRAPHIFY_API_TIMEOUT (seconds) for explicit override; # default to 600s, which is long enough for a 31B model on a 16k chunk # but still bounds runaway connections (issue #792 addendum). - timeout_raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip() - timeout_s: float = 600.0 - if timeout_raw: - try: - v = float(timeout_raw) - if v > 0: - timeout_s = v - except ValueError: - pass - client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout_s) + client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout()) kwargs: dict = { "model": model, "messages": [ @@ -550,7 +554,7 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = except ImportError as exc: raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc - client = anthropic.Anthropic(api_key=api_key) + client = anthropic.Anthropic(api_key=api_key, timeout=_resolve_api_timeout()) resp = client.messages.create( model=model, max_tokens=max_tokens, @@ -637,7 +641,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo capture_output=True, text=True, encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 - timeout=600, + timeout=_resolve_api_timeout(), check=False, ) if proc.returncode != 0: @@ -1200,7 +1204,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: capture_output=True, text=True, encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 - timeout=600, + timeout=_resolve_api_timeout(), check=False, ) if proc.returncode != 0: diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py index eeb6fd27..719e75b1 100644 --- a/tests/test_claude_cli_backend.py +++ b/tests/test_claude_cli_backend.py @@ -193,3 +193,38 @@ def test_non_windows_uses_bare_claude(monkeypatch): argv = run.call_args.args[0] assert argv[0] == "claude" + + +# ---------- GRAPHIFY_API_TIMEOUT honoured by all backends ---------- + + +def test_resolve_api_timeout_default(monkeypatch): + monkeypatch.delenv("GRAPHIFY_API_TIMEOUT", raising=False) + assert llm._resolve_api_timeout() == 600.0 + + +def test_resolve_api_timeout_env_override(monkeypatch): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "45") + assert llm._resolve_api_timeout() == 45.0 + + +def test_resolve_api_timeout_ignores_invalid(monkeypatch): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "not-a-number") + assert llm._resolve_api_timeout() == 600.0 + + +def test_resolve_api_timeout_ignores_nonpositive(monkeypatch): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "0") + assert llm._resolve_api_timeout() == 600.0 + + +def test_claude_cli_extraction_honours_timeout(monkeypatch, fake_claude): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "30") + llm._call_claude_cli("dummy", max_tokens=8192) + assert fake_claude.call_args.kwargs["timeout"] == 30.0 + + +def test_call_llm_claude_cli_branch_honours_timeout(monkeypatch, fake_claude): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "30") + llm._call_llm(prompt="x", backend="claude-cli", max_tokens=10) + assert fake_claude.call_args.kwargs["timeout"] == 30.0