You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AI provider requests to private/allowlisted hosts fail with generic "fetch failed" (undici dispatcher ABI mismatch — same class as #1185, not covered by #1291's fix) #1298
Configuring any AI provider whose base URL resolves to a private/internal address — including the two cases this feature explicitly exists for, a self-hosted Ollama on localhost and any other host added to "Allowed private AI hosts" — fails every request with a generic fetch failed error in the UI. The real cause never surfaces to the user: it's an ABI mismatch between Node's built-in fetch and the app's own undici dependency when a custom Agent dispatcher is passed to the global fetch, thrown as InvalidArgumentError: invalid onRequestStart method (code: UND_ERR_INVALID_ARG).
This is the same bug class already found and fixed for the Tailscale integration in #1185 / PR #1291 ("fix: use compatible fetch dispatcher for Tailscale", merged 2026-08-21) — but that fix only touched tailscale-routes.ts and proxy-agent.ts. It never reached src/backend/ai/providers/http.ts, which still has the vulnerable pattern.
Confirmed live against the running container: node -v → v24.19.0, require("undici/package.json").version → 8.10.0
Reproduced by reading source at release-2.7.1-tag and confirmed unchanged on current main HEAD (src/backend/ai/providers/http.ts last touched in the 2.7.0 commit that introduced it, per its own git history — PR fix: use compatible fetch dispatcher for Tailscale #1291 never touched this file)
Steps to reproduce
Enable the AI assistant (admin: AI assistant toggle; user: the corresponding preference).
Add any host to "Allowed private AI hosts" in Admin Settings (or use the default localhost entry).
Add an AI provider whose base URL is that private host (e.g. openai_compatible pointed at http://localhost:11434/v1, or any RFC1918 address you've allowlisted).
Send a message in the AI panel, or click "Refresh list" on the model picker.
Observe: fetch failed, with no further detail, and no server-side log line for it either (the /ai/probe-models and /ai/chat/stream handlers catch the error and only ever surface err.message, which for this class of error is just the generic wrapper string).
Root cause (read from source, not guessed)
src/backend/ai/providers/http.ts's providerFetch() branches on whether the destination is a private/allowlisted address:
The isPrivate branch calls the bare global fetch (Node's built-in, backed by its own bundled internal undici) with a dispatcher built by getFetchDispatcher() in src/backend/utils/proxy-agent.ts, which constructs its Agent/ProxyAgent from the app's own require("undici") npm dependency — a differentundici install (currently pinned to 8.10.0 in package.json) than the one bundled inside Node 24. Passing a dispatcher from one undici install into the fetch implementation of a different one throws at the Client.dispatch layer:
InvalidArgumentError: invalid onRequestStart method
at assertRequestHandler (node_modules/undici/lib/core/util.js:573:11)
at new Request (node_modules/undici/lib/core/request.js:271:5)
...
code: 'UND_ERR_INVALID_ARG'
undici's internal fetch() wraps any dispatcher-thrown error as a generic TypeError: fetch failed with the real error attached only as .cause — which is why the UI (and the server logs, since neither the /ai/probe-models nor /ai/chat/stream route logs err.cause) never shows anything more specific than fetch failed.
This reproduces for any target, including 127.0.0.1 inside the same container that's serving the request — it isn't specific to a particular network path, firewall, or provider.
The safeOutboundFetch branch (src/backend/utils/safe-outbound-fetch.ts), by contrast, imports fetch as undiciFetch directly from the app's own undici package and uses it consistently with its own Agent — same install, no ABI mismatch, which is presumably why the non-private path has never shown this symptom.
Suggested fix
Same shape as #1291's fix for Tailscale: have providerFetch()'s private branch call fetchWithProxy() (or an equivalent that sources both the dispatcher and the fetch implementation from the app's own undici package) instead of the bare global fetch + a foreign dispatcher. src/backend/utils/proxy-agent.ts already exports fetchWithProxy for exactly this purpose — src/backend/ai/providers/http.ts just isn't using it.
Also worth surfacing err.cause (or err.cause?.message) alongside err.message wherever a provider fetch error reaches a route response or a log line — this class of error is silent otherwise, at both the UI and the server log, which made this much harder to diagnose than the fix itself.
Happy to test a fix against a self-hosted instance if useful.
Summary
Configuring any AI provider whose base URL resolves to a private/internal address — including the two cases this feature explicitly exists for, a self-hosted Ollama on
localhostand any other host added to "Allowed private AI hosts" — fails every request with a genericfetch failederror in the UI. The real cause never surfaces to the user: it's an ABI mismatch between Node's built-infetchand the app's ownundicidependency when a customAgentdispatcher is passed to the global fetch, thrown asInvalidArgumentError: invalid onRequestStart method(code: UND_ERR_INVALID_ARG).This is the same bug class already found and fixed for the Tailscale integration in #1185 / PR #1291 ("fix: use compatible fetch dispatcher for Tailscale", merged 2026-08-21) — but that fix only touched
tailscale-routes.tsandproxy-agent.ts. It never reachedsrc/backend/ai/providers/http.ts, which still has the vulnerable pattern.Environment
ghcr.io/lukegus/termix:2.7.1)node -v→ v24.19.0,require("undici/package.json").version→ 8.10.0release-2.7.1-tagand confirmed unchanged on currentmainHEAD (src/backend/ai/providers/http.tslast touched in the 2.7.0 commit that introduced it, per its own git history — PR fix: use compatible fetch dispatcher for Tailscale #1291 never touched this file)Steps to reproduce
AI assistanttoggle; user: the corresponding preference).localhostentry).openai_compatiblepointed athttp://localhost:11434/v1, or any RFC1918 address you've allowlisted).fetch failed, with no further detail, and no server-side log line for it either (the/ai/probe-modelsand/ai/chat/streamhandlers catch the error and only ever surfaceerr.message, which for this class of error is just the generic wrapper string).Root cause (read from source, not guessed)
src/backend/ai/providers/http.ts'sproviderFetch()branches on whether the destination is a private/allowlisted address:The
isPrivatebranch calls the bare globalfetch(Node's built-in, backed by its own bundled internalundici) with adispatcherbuilt bygetFetchDispatcher()insrc/backend/utils/proxy-agent.ts, which constructs itsAgent/ProxyAgentfrom the app's ownrequire("undici")npm dependency — a differentundiciinstall (currently pinned to8.10.0inpackage.json) than the one bundled inside Node 24. Passing a dispatcher from oneundiciinstall into thefetchimplementation of a different one throws at theClient.dispatchlayer:undici's internal
fetch()wraps any dispatcher-thrown error as a genericTypeError: fetch failedwith the real error attached only as.cause— which is why the UI (and the server logs, since neither the/ai/probe-modelsnor/ai/chat/streamroute logserr.cause) never shows anything more specific thanfetch failed.Minimal reproduction, run inside the container:
This reproduces for any target, including
127.0.0.1inside the same container that's serving the request — it isn't specific to a particular network path, firewall, or provider.The
safeOutboundFetchbranch (src/backend/utils/safe-outbound-fetch.ts), by contrast, importsfetch as undiciFetchdirectly from the app's ownundicipackage and uses it consistently with its ownAgent— same install, no ABI mismatch, which is presumably why the non-private path has never shown this symptom.Suggested fix
Same shape as #1291's fix for Tailscale: have
providerFetch()'s private branch callfetchWithProxy()(or an equivalent that sources both the dispatcher and the fetch implementation from the app's ownundicipackage) instead of the bare globalfetch+ a foreign dispatcher.src/backend/utils/proxy-agent.tsalready exportsfetchWithProxyfor exactly this purpose —src/backend/ai/providers/http.tsjust isn't using it.Also worth surfacing
err.cause(orerr.cause?.message) alongsideerr.messagewherever a provider fetch error reaches a route response or a log line — this class of error is silent otherwise, at both the UI and the server log, which made this much harder to diagnose than the fix itself.Happy to test a fix against a self-hosted instance if useful.