diff --git a/README.md b/README.md index 1e4467e74..b45719975 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,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 every backend, HTTP and `claude-cli` alike (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 c67014b4a..407fda538 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -157,6 +157,25 @@ 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 (seconds), else use the default. + + Applies uniformly across every backend — the OpenAI-compatible HTTP client + and the claude-cli subprocess paths alike — so a slow local model or a long + chunk can be given more headroom without patching the source. Non-positive + or unparseable values fall back to the default (issue #792 addendum). + """ + 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. @@ -362,19 +381,10 @@ def _call_openai_compat( # Local backends (ollama, llama.cpp, vLLM) routinely take >60s for a # single chunk on a large model — far longer than the openai SDK's - # 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) + # default. GRAPHIFY_API_TIMEOUT (seconds) overrides; defaults to 600s, + # long enough for a 31B model on a 16k chunk but still bounding a runaway + # connection (issue #792 addendum). + client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout()) kwargs: dict = { "model": model, "messages": [ @@ -571,7 +581,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: @@ -1134,7 +1144,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 eeb6fd27b..f204ee53a 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" + + +# ---------- timeout honouring: GRAPHIFY_API_TIMEOUT reaches claude-cli ---------- + + +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_subprocess_uses_default_timeout(fake_claude, monkeypatch): + monkeypatch.delenv("GRAPHIFY_API_TIMEOUT", raising=False) + llm._call_claude_cli("dummy", max_tokens=8192) + assert fake_claude.call_args.kwargs["timeout"] == 600.0 + + +def test_claude_cli_subprocess_honours_api_timeout_env(fake_claude, monkeypatch): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "30") + llm._call_claude_cli("dummy", max_tokens=8192) + assert fake_claude.call_args.kwargs["timeout"] == 30.0