Skip to content

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

Description

@prashantsolanki3

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 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.

Environment

  • Self-hosted, Docker/Kubernetes deployment (ghcr.io/lukegus/termix:2.7.1)
  • 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

  1. Enable the AI assistant (admin: AI assistant toggle; user: the corresponding preference).
  2. Add any host to "Allowed private AI hosts" in Admin Settings (or use the default localhost entry).
  3. 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).
  4. Send a message in the AI panel, or click "Refresh list" on the model picker.
  5. 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:

if (decision.isPrivate) {
  return fetch(url, {
    ...init,
    dispatcher: getFetchDispatcher(url),
  } as RequestInit);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;

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 different undici 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.

Minimal reproduction, run inside the container:

const { Agent } = require("undici");
const directAgent = new Agent({ connect: { autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 } });
fetch("http://127.0.0.1:8080/health", { method: "GET", dispatcher: directAgent })
  .catch(e => { console.log("ERROR", e.message); console.log("CAUSE", e.cause && e.cause.message); });
// ERROR fetch failed
// CAUSE invalid onRequestStart method

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions