8000 fix(llm): ask a server that dropped the connection once more, and let a thinking model finish by adeelahmad · Pull Request #705 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/llm-retry-dropped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **A busy local model server no longer fails jobs on a dropped connection.** When the LLM server hangs up on a
request (an Ollama box loading another model resets it), Lens asks once more two seconds later instead of failing
the job. A reasoning model that spends a short answer's whole length thinking, such as a conversation's title, is
asked again without the limit instead of leaving the title unwritten
([A busy local model server](docs/configuration.md#a-busy-local-model-server)).
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ Changing the model drops the stored vectors (another model's can't be compared)
doesn't have the model), indexing jobs skip for ten minutes rather than each waiting on it, and the hourly routine
waits too; **Test** in Settings → Search, **Index now**, or `lens embed` try again at once.

## A busy local model server

One local server serving the chat model, a vision model and embeddings (an Ollama box, say) unloads one to load the
next, and while it does it may hang up on a request. Lens asks again once, two seconds later, when the server drops
the connection; a server that refuses the request, can't be reached or runs past `llm.timeout` (300 seconds) isn't
asked again, and the error says which it was. A reasoning model (Qwen 3, DeepSeek R1) asked for something short, such
as a conversation's title, can spend the whole length thinking and say nothing; Lens then asks it again without the
limit. Ollama keeps more models loaded at once with `OLLAMA_MAX_LOADED_MODELS` on the Ollama machine.

## Cloud model providers

The setup wizard's model provider step and Settings → LLM provider offer the model servers running nearby (Ollama, LM
Expand Down
51 changes: 44 additions & 7 deletions fastapi_backend/app/domain/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,11 @@ def _post(cfg, payload, whole=False):
return _bedrock(cfg, payload, region)
tries = _spread(cfg)[:2] # a unit that can't be reached hands the call to the next
for n, at in enumerate(tries, 1):
req = chat_request(at, payload, whole)
try:
r = urllib.request.urlopen(req, timeout=l.get("timeout") or 300, **_tls(l))
if "vnd.amazon.eventstream" in (r.headers.get("Content-Type") or ""):
return PayloadParts(r)
return r
return _send(at, payload, whole, l)
except urllib.error.HTTPError as e:
said = e.read().decode("utf-8", "replace")
if e.code == 403 and req.full_url.endswith("/invocations-response-stream"):
if e.code == 403 and payload.get("stream") and not whole and sagemaker_region(at): # was /invocations-response-stream
return _post(at, payload, whole=True) # keys that may only InvokeEndpoint, as before answers streamed
if e.code in (400, 413) and FULL_SAYS.search(said):
m = WINDOW_SAYS.search(said)
Expand All @@ -98,6 +94,34 @@ def _post(cfg, payload, whole=False):
raise LLMError(f"can't reach the LLM server at {at['llm']['base_url']}: {e}") from None


DROPPED = (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) # RemoteDisconnected is a ConnectionResetError
REDIAL_AFTER = 2 # seconds before asking again a server that dropped the connection


def _dropped(e):
"""True when the server hung up on the request (a busy Ollama loading another model resets it) rather than being
unreachable, slow or refusing it."""
return isinstance(e, DROPPED) or isinstance(getattr(e, "reason", None), DROPPED)


def _send(cfg, payload, whole, l):
"""The server's open answer to one chat request, asked once more when the server dropped the connection."""
for again in (True, False):
req = chat_request(cfg, payload, whole) # anew each time: an AWS signature is for one moment
try:
r = urllib.request.urlopen(req, timeout=l.get("timeout") or 300, **_tls(l))
except urllib.error.HTTPError:
raise
except (urllib.error.URLError, OSError) as e:
if not (again and _dropped(e)):
raise
time.sleep(REDIAL_AFTER)
continue
if "vnd.amazon.eventstream" in (r.headers.get("Content-Type") or ""):
return PayloadParts(r)
return r


_CONTEXTS = {} # llm.tls_ca -> its SSL context


Expand Down Expand Up @@ -426,12 +450,25 @@ def _content(j):
8000 raise LLMError("unexpected reply from the LLM server") from None


def _thought_out(j):
"""True when a reasoning model spent all of max_tokens thinking: cut at the length with nothing said, its thoughts
in `reasoning` or `reasoning_content` (Ollama, vLLM, LM Studio) or an unclosed <think> block."""
try:
choice = j["choices"][0]
return choice.get("finish_reason") == "length" and not unthink(choice["message"].get("content") or "").strip()
except (KeyError, IndexError, TypeError, AttributeError):
return False


def chat(cfg, messages, model=None, max_tokens=None, temperature=0.2):
payload = {"model": model or cfg["llm"]["model"], "messages": messages, "temperature": temperature}
if max_tokens:
payload["max_tokens"] = max_tokens
with telemetry.model_call(cfg, payload) as call, _post(cfg, payload) as r:
return _content(call.reply(_load(r)))
j = call.reply(_load(r))
if max_tokens and _thought_out(j): # a short answer (a title) from a model that thinks first: let it finish
return chat(cfg, messages, model, None, temperature)
return _content(j)


def _visible(pieces):
Expand Down
103 changes: 103 additions & 0 deletions fastapi_backend/tests/domain/test_llm_resilience.py
8000
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""A busy local server (one Ollama box switching models) drops connections, and a reasoning model given a short
max_tokens spends it all thinking: Lens asks once more instead of failing the job or the conversation's title."""

from __future__ import annotations

import http.client
import io
import json
import urllib.error

import pytest

from app.domain import llm


class Answer(io.BytesIO):
def __init__(self, body):
super().__init__(json.dumps(body).encode())
self.headers = {"Content-Type": "application/json"}


def said(content, finish="stop", **extra):
return Answer({"choices": [{"message": {"role": "assistant", "content": content, **extra}, "finish_reason": finish}]})


@pytest.fixture
def sent(cfg, monkeypatch):
cfg["llm"].update(base_url="http://ollama.test:11434/v1", model="qwen3", api_key=None, api_key_env=None)
calls, answers = [], []

def urlopen(req, timeout=None, **kw):
calls.append(json.loads(req.data))
a = answers.pop(0)
if isinstance(a, Exception):
raise a
return a

monkeypatch.setattr(llm.urllib.request, "urlopen", urlopen)
monkeypatch.setattr(llm, "REDIAL_AFTER", 0)
return calls, answers


@pytest.mark.parametrize(
"drop",
[
urllib.error.URLError(ConnectionResetError(104, "Connection reset by peer")),
urllib.error.URLError(http.client.RemoteDisconnected("Remote end closed connection without response")),
ConnectionResetError(104, "Connection reset by peer"),
],
)
def test_a_dropped_connection_is_asked_once_more(cfg, sent, drop):
calls, answers = sent
answers += [drop, said("Hi.")]
assert llm.chat(cfg, [{"role": "user", "content": "hello"}]) == "Hi."
assert len(calls) == 2


def test_a_server_that_keeps_dropping_says_why_after_the_second_try(cfg, sent):
calls, answers = sent
reset = urllib.error.URLError(ConnectionResetError(104, "Connection reset by peer"))
answers += [reset, reset]
with pytest.raises(llm.LLMError, match="can't reach the LLM server at http://ollama.test:11434/v1: .*reset by peer"):
llm.chat(cfg, [{"role": "user", "content": "hello"}])
assert len(calls) == 2


@pytest.mark.parametrize("why", [TimeoutError("timed out"), urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))])
def test_a_slow_or_absent_server_is_not_asked_again(cfg, sent, why):
calls, answers = sent
answers.append(why)
with pytest.raises(llm.LLMError, match="can't reach the LLM server"):
llm.chat(cfg, [{"role": "user", "content": "hello"}])
assert len(calls) == 1


def test_a_refusal_is_not_asked_again(cfg, sent):
calls, answers = sent
answers.append(urllib.error.HTTPError("http://ollama.test", 400, "Bad Request", {}, io.BytesIO(b'{"error":"bad"}')))
with pytest.raises(llm.LLMError, match="^400 from the LLM server"):
llm.chat(cfg, [{"role": "user", "content": "hello"}])
assert len(calls) == 1


@pytest.mark.parametrize(
"thinking",
[
said("", "length", reasoning="Okay, the user wants a title"), # Ollama
said(None, "length", reasoning_content="Okay, the user wants"), # vLLM, LM Studio
said("<think>Okay, the user wants a", "length"), # thoughts inline, never closed
],
)
def test_a_reasoning_model_that_spent_max_tokens_thinking_is_let_finish(cfg, sent, thinking):
calls, answers = sent
answers += [thinking, said("<think>A greeting.</think>Saying hello")]
assert llm.chat(cfg, [{"role": "user", "content": "hello"}], max_tokens=24) == "Saying hello"
assert calls[0]["max_tokens"] == 24 and "max_tokens" not in calls[1]


def test_an_answer_cut_at_the_length_with_words_in_it_stands(cfg, sent):
calls, answers = sent
answers.append(said("Saying hel", "length"))
assert llm.chat(cfg, [{"role": "user", "content": "hello"}], max_tokens=4) == "Saying hel"
assert len(calls) == 1
Loading
0