Merge pull request #28 from darth-veitcher/fix/ollama-incomplete-response-race

fix(ollama): raise a clear error on incomplete done:false stub
This commit is contained in:
James Veitch
2026-07-23 07:59:27 +01:00
committed by GitHub
2 changed files with 110 additions and 3 deletions
+22 -3
View File
@@ -280,6 +280,7 @@ class OllamaProvider:
payload_messages = [m.model_dump() for m in messages]
total_attempts = max(0, min(int(max_retries), 5)) + 1
response_text = ""
incomplete = False
for attempt in range(1, total_attempts + 1):
attempt_options = dict(options) if options else {}
@@ -317,12 +318,30 @@ class OllamaProvider:
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
return response_text
# done: false alongside blank content is a distinct signal from
# an ordinary blank generation — it's Ollama answering before
# the model has actually finished loading/swapping in, observed
# live under model-swap load (issue #27), not the model having
# genuinely generated nothing. Tracked separately so it can be
# raised on below instead of silently returned like a real
# blank generation would be.
incomplete = result.get("done") is False
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
# Every attempt came back blank — never raises here (chat() has
# never validated its output, unlike chat_structured()); return the
# last (blank) attempt uncached so the next queue run tries fresh.
if incomplete:
raise RuntimeError(
f"Ollama returned an incomplete response after "
f"{total_attempts} attempt(s) for model '{model}' — it may "
"still be loading or swapping in memory. Try again in a "
"few seconds."
)
# Every attempt came back blank (and complete) — never raises here
# (chat() has never validated its output, unlike chat_structured());
# return the last (blank) attempt uncached so the next queue run
# tries fresh.
return response_text
async def chat_structured(
+88
View File
@@ -386,6 +386,94 @@ def test_chat_no_retry_needed_does_not_sleep(monkeypatch):
assert sleep_calls == []
# ---------------------------------------------------------------------------
# chat — incomplete (done: false) response distinct from a genuine blank
# generation (issue #27)
# ---------------------------------------------------------------------------
#
# Live-observed against a real Ollama server under model-swap load: /api/chat
# can answer HTTP 200 with a `done: false` stub — model/created_at/message
# all blank — before the target model has actually finished loading. Unlike
# a genuine blank generation, chat() must not fold this into a silent "".
def test_chat_raises_when_every_attempt_is_an_incomplete_stub(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
return {
"model": "",
"created_at": "0001-01-01T00:00:00Z",
"message": {"role": "", "content": ""},
"done": False,
}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
with pytest.raises(RuntimeError, match="incomplete response"):
_run_async(
OllamaProvider("http://localhost:11434").chat(
"llama3", [Message(role="user", content="hi")], max_retries=2
)
)
assert len(calls) == 3 # original + 2 retries, per max_retries=2
def test_chat_recovers_after_incomplete_stub_then_real_response(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
if len(calls) == 1:
return {
"model": "",
"created_at": "0001-01-01T00:00:00Z",
"message": {"role": "", "content": ""},
"done": False,
}
return {"message": {"content": "real answer"}}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
result = _run_async(
OllamaProvider("http://localhost:11434").chat(
"llama3", [Message(role="user", content="hi")]
)
)
assert result == "real answer"
assert len(calls) == 2
def test_chat_exhausted_retries_still_returns_blank_without_raising_when_done_true(
monkeypatch,
):
"""Regression guard: a genuine blank generation (done: true, or no
`done` field at all) must keep the pre-existing silent-return behavior —
only the explicit done: false stub escalates to a raised error."""
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
return {"message": {"content": ""}, "done": True}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
result = _run_async(
OllamaProvider("http://localhost:11434").chat(
"llama3", [Message(role="user", content="hi")], max_retries=2
)
)
assert result == ""
assert len(calls) == 3
# ---------------------------------------------------------------------------
# chat_structured
# ---------------------------------------------------------------------------