Repository navigation
fix(providers): disable HTTP keepalive for local/LAN endpoints - #3444
Conversation
Local model servers (Ollama, llama.cpp, vLLM) often close idle HTTP connections before the client-side keepalive timer expires. When two LLM calls happen seconds apart — for example the heartbeat _decide() phase followed immediately by process_direct() — the second call grabs a now-dead pooled connection, causing a transient APIConnectionError on every first attempt. The fix detects local endpoints via: - ProviderSpec.is_local (Ollama, LM Studio, vLLM, OVMS) - Private-network URL patterns (localhost, 127.x, 192.168.x, 10.x, 172.16-31.x, host.docker.internal, [::1]) For these endpoints, the AsyncOpenAI client is created with a custom httpx.AsyncClient that sets keepalive_expiry=0, forcing a fresh TCP connection for each request. This is cheap on LAN (sub-5ms connect) and eliminates the stale-connection retry tax entirely. Cloud providers (OpenAI, Anthropic, OpenRouter, etc.) keep the default 5-second keepalive, which is fine for high-frequency API usage. The private-network heuristic also covers the common case where users configure provider='openai' but point apiBase at a LAN IP running llama.cpp — the spec says is_local=False, but the URL clearly is.
Parse the endpoint host before disabling keepalive so public hostnames that merely contain private-network substrings keep the default connection pool behavior. Made-with: Cursor
Re-bin
left a comment
There was a problem hiding this comment.
This is the right fix for the local/LAN provider path.
The problem is real: local model servers can close idle HTTP connections before the OpenAI/httpx client notices, so reusing pooled keepalive connections creates predictable first-attempt APIConnectionErrors. Disabling keepalive only for local endpoints is a narrow fix and keeps cloud providers on the normal pooled behavior.
I pushed one small follow-up to tighten the endpoint detection. The original heuristic used substring matching, which could classify public hostnames like notlocalhost.example or api10.example.com as local. The updated version parses the URL hostname first, then uses ipaddress for loopback/private IPs, plus explicit localhost / host.docker.internal handling.
Validated locally:
python -m pytest tests/providers/test_local_endpoint_detection.py -qpython -m pytest tests/providers -qpython -m ruff check nanobot/providers/openai_compat_provider.py tests/providers/test_local_endpoint_detection.pygit diff --check origin/main...HEAD
From my side, the behavior is now well-scoped and ready to merge once CI is green.
Problem
Local model servers (Ollama, llama.cpp, vLLM) close idle HTTP connections on their own timeout schedule. When two LLM calls happen seconds apart — for example the heartbeat
_decide()phase followed immediately byprocess_direct()— the second call reuses a pooled connection that the server has already closed, causing a transientAPIConnectionErroron every first attempt.Observed behavior: 100% first-attempt failure rate on heartbeat execution when using llama.cpp over LAN (192.168.x), with successful retry every time. The retry adds ~1-6 seconds of unnecessary latency to every heartbeat cycle.
Root Cause
The
AsyncOpenAIclient uses httpx with default connection pooling (keepalive_expiry=5.0s). After the first LLM call completes (~30s for a local model), the connection returns to the pool. The next call a few seconds later finds the connection in the pool, but the server (llama.cpp withKeep-Alive: timeout=5) may have already closed it. httpx detects the dead connection only after attempting to use it →APIConnectionError.Fix
Detect local endpoints via two mechanisms:
ProviderSpec.is_local— covers Ollama, LM Studio, vLLM, OVMSprovider: openaiwithapiBasepointing at a LAN IP (localhost, 127.x, 192.168.x, 10.x, 172.16-31.x, host.docker.internal, [::1])For detected local endpoints, create the
AsyncOpenAIclient with a customhttpx.AsyncClientthat setskeepalive_expiry=0, forcing a fresh TCP connection per request. This is cheap on LAN (sub-5ms connect time) and eliminates the stale-connection retry entirely.Cloud providers keep the default 5s keepalive.
Tests
21 new tests covering:
_is_local_endpoint()detection for all private network patternsAll 295 provider tests pass (274 existing + 21 new).