diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe4e33..7d96c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved. - Fix: token estimation no longer crashes on files containing tiktoken special-token text like `<|endoftext|>` (#1685, thanks @Kyzcreig). `_TOKENIZER.encode(content)` raises `ValueError` by default when the text contains a special token, which aborted packing on docs/corpora that merely mention these strings. Both `encode` sites now pass `disallowed_special=()` so such text is tokenized as ordinary bytes. +- Fix: the Ollama backend no longer multiplies a hang by the retry count (#1686, thanks @Kyzcreig). A stalled local model would wedge for `timeout * (max_retries + 1)`, which with the default 6 retries turned one long stall into a very long one. Ollama now defaults to zero client-side retries (a local model that stalls will not un-stall on retry); set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged. Note: the underlying stall is non-deterministic and driven by the model server, so this bounds the wait rather than eliminating the hang. ## 0.9.7 (2026-07-06) diff --git a/graphify/llm.py b/graphify/llm.py index 62dd4b6..7933403 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -962,8 +962,18 @@ 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). + # The SDK's transient-error retries (default 6) exist for cloud rate limits + # (429). A local Ollama server does not rate-limit, and if it wedges it will + # not recover by retrying, so 6 retries turn a 180s --api-timeout into a + # ~21min block (7 attempts x 180s) with no progress (#1686). Default ollama + # to 0 SDK retries so --api-timeout is the hard wall-clock bound and a hung + # request fails fast into the chunk-level retry/skip. An explicit + # GRAPHIFY_MAX_RETRIES still wins for users who want it. + _retries = _resolve_max_retries() + if backend == "ollama" and not os.environ.get("GRAPHIFY_MAX_RETRIES", "").strip(): + _retries = 0 client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout(), - max_retries=_resolve_max_retries()) + max_retries=_retries) kwargs: dict = { "model": model, "messages": [ diff --git a/tests/test_ollama_retry_cap.py b/tests/test_ollama_retry_cap.py new file mode 100644 index 0000000..5ad43cc --- /dev/null +++ b/tests/test_ollama_retry_cap.py @@ -0,0 +1,60 @@ +"""#1686 - a wedged local Ollama request must not multiply --api-timeout by the +SDK's 6 transient-error retries into a ~20min block. Ollama defaults to 0 SDK +retries so the timeout is the effective wall-clock bound; an explicit +GRAPHIFY_MAX_RETRIES still wins. +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import graphify.llm as llm + + +def _capture_client_kwargs(monkeypatch): + captured: dict = {} + + def _factory(**kwargs): + captured.update(kwargs) + client = MagicMock() + resp = MagicMock() + resp.choices[0].message.content = '{"nodes": [], "edges": [], "hyperedges": []}' + resp.choices[0].finish_reason = "stop" + resp.usage.prompt_tokens = 1 + resp.usage.completion_tokens = 1 + client.chat.completions.create.return_value = resp + return client + + monkeypatch.setattr("openai.OpenAI", _factory) + return captured + + +def test_ollama_defaults_to_zero_sdk_retries(monkeypatch): + monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False) + captured = _capture_client_kwargs(monkeypatch) + llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m", + "def f(): pass", backend="ollama") + assert captured.get("max_retries") == 0 + + +def test_ollama_honors_explicit_max_retries(monkeypatch): + monkeypatch.setenv("GRAPHIFY_MAX_RETRIES", "3") + captured = _capture_client_kwargs(monkeypatch) + llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m", + "def f(): pass", backend="ollama") + assert captured.get("max_retries") == 3 + + +def test_cloud_backend_keeps_default_retries(monkeypatch): + monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False) + captured = _capture_client_kwargs(monkeypatch) + llm._call_openai_compat("https://api.moonshot.cn/v1", "sk-x", "m", + "def f(): pass", backend="kimi") + assert captured.get("max_retries") == 6 # default retained for rate-limited clouds + + +def test_api_timeout_is_passed_to_client(monkeypatch): + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "180") + captured = _capture_client_kwargs(monkeypatch) + llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m", + "def f(): pass", backend="ollama") + assert captured.get("timeout") == 180.0