Skip to content

fix(ollama): accept Ollama's own OLLAMA_HOST format, and keep the timeout armed through the body read - #88

Merged
AetherAI3 merged 1 commit into
mainfrom
fix/ollama-host-normalization
Aug 20, 2026
Merged

fix(ollama): accept Ollama's own OLLAMA_HOST format, and keep the timeout armed through the body read#88
AetherAI3 merged 1 commit into
mainfrom
fix/ollama-host-normalization

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

The defect

Two problems on the offline Ollama path — the on-ramp the README advertises for people who have not signed in ("…or skip it and run on your own Ollama"), so it is the first thing a new user without an account hits.

1. OLLAMA_HOST in Ollama's own format broke every local turn

Ollama's convention for OLLAMA_HOST is a scheme-less host:port. That is what ollama serve prints, and what it binds: 127.0.0.1:11434, 0.0.0.0:11434. The client took the variable and concatenated /v1/chat/completions onto it raw, so setting the variable the way Ollama documents it produced:

fetch("127.0.0.1:11434/v1/chat/completions")  ->  TypeError: Failed to parse URL

which fell into the "cannot reach Ollama" handler, which told the user to set OLLAMA_HOST — the exact thing they had just done. Reproduced verbatim before the fix:

Cannot reach Ollama at 127.0.0.1:11434. Is it running? Start it with 'ollama serve'
(default port 11434), or set OLLAMA_HOST.
Underlying error: Failed to parse URL from 127.0.0.1:11434/v1/chat/completions

All three call sites were unnormalized — src/core/ollama.ts, src/core/smoke.ts, scripts/handoff-demo.ts — so npm run smoke and the handoff demo echoed the same broken value back instead of diagnosing it. Impact: for a user who followed Ollama's own documentation, the entire local backend was unusable and the error message actively pointed away from the cause.

2. The request timeout was disarmed before the body was read

ollamaChat armed an AbortController timer, then cleared it in the finally attached to the fetch try/catch — i.e. as soon as response headers arrived. Both body reads, await res.text() on the error path and await res.json() on the success path, then ran with no timer and no signal.

Because the client sends stream: false, the entire completion is in that body. So a server that returned 200 headers and then stalled mid-body — a real failure mode for a loaded local GPU or a dropped SSH tunnel in front of a remote Ollama — hung the turn forever instead of failing at DEFAULT_TIMEOUT_MS. The user saw a frozen agent indistinguishable from slow inference. Demonstrated against a stub that writes 200 headers and then never ends the body, using the pre-fix control-flow shape with a 400 ms timeout configured:

headers ok: 200 - now reading body with no timer armed...
RESULT: STILL HANGING after 3000ms

What changed

src/core/ollama.ts — new exported pure function normalizeOllamaHost(raw):

Input Result Why
(unset / empty / whitespace) http://localhost:11434 the existing default
127.0.0.1:11434 http://127.0.0.1:11434 scheme-less, Ollama's own form
localhost:11434 http://localhost:11434 scheme-less
0.0.0.0:11434 http://127.0.0.1:11434 0.0.0.0 is a bind address, not a connect address
http://localhost:11434/// http://localhost:11434 trailing slashes stripped
https://ollama.example.com/proxy/ https://ollama.example.com/proxy remote/proxied Ollama preserved
ftp://localhost:11434, ::: throws, naming the value fails up front instead of as "cannot reach Ollama"

Also in ollama.ts: request and parse now share one try/finally, so clearTimeout happens after the body read rather than after the headers, and the AbortError branch is reachable from both body-read paths. A stalled body now surfaces as the same Ollama request timed out after Ns message the connect path already produced.

src/core/smoke.tsollamaUp() normalizes before building /api/tags, and smokeMain() normalizes once up front. If the value is unusable, the ollama and local turn checks now report a FAIL carrying the host error instead of a misleading SKIP: not reachable. That is the behavior change that makes the doctor diagnose the problem rather than restate it.

scripts/handoff-demo.ts — normalizes the host it hands to the child CLI via env.

COMMANDS.md — the OLLAMA_HOST row now says the scheme-less form is accepted, and a new "OLLAMA_HOST accepted forms" table under Environment variables documents every form above.

test/ollama.test.ts — 8 new tests: the normalizeOllamaHost table (scheme-less, 0.0.0.0, trailing slashes, empty/undefined, rejection messages), an end-to-end ollamaChat turn against a genuinely scheme-less host:port (the regression test for defect 1), a bad-host rejection assertion, and a stalled-body test against a stub that writes 200 headers and never ends — it now fails at the configured timeout in ~420 ms instead of hanging.

What I deliberately did not touch

  • src/core/tool_executor.ts, src/core/git_commit_guard.ts, src/core/brain_cloud.ts and src/commands/** belong to a sibling lane.
  • smoke.ts feeds one model value to both checkLocalTurn and checkCloudTurn, which looks like a bug (a cloud model name would be probed against Ollama). On reading checkCloudTurn it is inert: the function does void model because the cloud model is server-selected today, and the parameter exists only for signature parity. There is no behavior to fix, so I left it rather than churn the signature. Worth revisiting if the cloud model ever becomes client-selected.
  • No change to the wire body, the sampling profiles, the tool-call recovery, or any public type.

Test evidence

$ npm ci && npm run build
added 4 packages, and audited 5 packages in 16s
found 0 vulnerabilities
> tsc -p tsconfig.json && node dist/scripts/copy-skill-assets.js
copied 18 built-in skill assets -> dist/src/skills/builtin

$ node --test --test-isolation=none "dist/test/ollama.test.js"
...
ok normalizeOllamaHost accepts the scheme-less host:port ollama serve prints
ok normalizeOllamaHost maps the 0.0.0.0 bind address to a connectable one
ok normalizeOllamaHost keeps explicit schemes and strips trailing slashes
ok normalizeOllamaHost falls back to the default for empty/whitespace/undefined
ok normalizeOllamaHost rejects unusable values and names the bad value
ok ollamaChat completes a turn against a scheme-less host:port
ok ollamaChat surfaces a bad OLLAMA_HOST as a host error, not 'cannot reach Ollama'
ok ollamaChat times out when the server stalls mid-body after 200 headers (419.289ms)
# tests 23 | pass 23 | fail 0

$ node --test --test-isolation=none "dist/test/**/*.test.js"
# tests 1125 | pass 1124 | fail 0 | skipped 1 | duration_ms 348063

The full suite was run with TEMP/TMP/TMPDIR pointed at a directory outside any git repository, because the worktree tests otherwise interact badly with a temp directory that sits inside a checkout on this machine. That is an environment workaround, not a change to the tests.

Blast radius

Contained to the local/Ollama path. normalizeOllamaHost is a new export with no existing caller, and for every input the old code handled correctly — a full http://… URL, with or without trailing slashes — it returns the same string the old trailing-slash strip did, so no working configuration changes behavior. The timeout restructure changes control flow inside ollamaChat only: the success path returns the same ChatReply, the 404 and non-2xx errors carry the same text, and the only new outcome is that a previously-infinite hang now becomes the existing timeout error. Cloud/hosted turns, transport, auth and the tool loop are untouched.

…eout armed through the body read

Two defects on the offline on-ramp, both on the path a signed-out user hits first.

1. Ollama's convention for OLLAMA_HOST is a scheme-less host:port — that is what
   `ollama serve` prints and binds. The client concatenated the value raw, so
   OLLAMA_HOST=127.0.0.1:11434 produced fetch("127.0.0.1:11434/v1/chat/completions")
   -> "Failed to parse URL", which the unreachable handler then reported as
   "Cannot reach Ollama ... or set OLLAMA_HOST" — the exact thing the user had
   just done. Every local turn failed, and `smoke` plus the handoff demo were
   equally unnormalized, so they echoed the same broken value instead of
   diagnosing it.

   Adds one exported pure function, normalizeOllamaHost(): trims, adds http://
   when there is no scheme, maps the 0.0.0.0 bind address to a connectable
   127.0.0.1, strips trailing slashes, and rejects anything unusable with a
   message naming the bad value. All three call sites route through it, and
   smokeMain now reports a bad host as its own FAIL rather than as "Ollama is
   down". Accepted forms are documented in COMMANDS.md.

2. The AbortController timer was cleared in the finally attached to the fetch,
   i.e. as soon as response headers arrived. Because stream:false puts the whole
   completion in the body, both body reads then ran with no timer and no signal:
   a server that answered 200 and stalled mid-body — a loaded local GPU, a
   dropped SSH tunnel — hung the turn forever instead of failing at the timeout.
   Request and parse now share one try/finally, and the AbortError branch is
   reachable from the body-read path, so a stalled body surfaces as the same
   "timed out after Ns" message the connect path already produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AetherAI3
AetherAI3 merged commit a361cdd into main Aug 20, 2026
5 checks passed
@AetherAI3
AetherAI3 deleted the fix/ollama-host-normalization branch August 20, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant