Skip to content

Commit a1c2e01

Browse files
committed
feat(distributed): add ExposeNodeHeader middleware + response writer wrapper
New ApplicationConfig.ExposeNodeHeader bool + --expose-node-header CLI flag / LOCALAI_EXPOSE_NODE_HEADER env var (default off; the node ID reveals internal topology and is opt-in). The middleware creates a per-request *atomic.Value holder, attaches it to c.Request().Context() via distributedhdr.WithHolder, and wraps c.Response().Writer with a custom http.ResponseWriter that sets the X-LocalAI-Node header on first Write / WriteHeader / Flush by reading the holder. Implements http.Flusher, http.Hijacker, Unwrap so it composes cleanly with Echo and http.NewResponseController. request.go propagates the holder onto derived contexts via distributedhdr.Inherit so the holder survives the correlation-ID context replacement. Unit + race-clean concurrency + integration specs. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 23d786a commit a1c2e01

7 files changed

Lines changed: 705 additions & 0 deletions

File tree

core/cli/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ type RunCMD struct {
157157
AutoApproveNodes bool `env:"LOCALAI_AUTO_APPROVE_NODES" default:"false" help:"Auto-approve new worker nodes (skip admin approval)" group:"distributed"`
158158
BackendInstallTimeout string `env:"LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT" help:"NATS round-trip timeout for backend.install requests sent to worker nodes (default 15m). Increase for slow links pulling multi-GB images." group:"distributed"`
159159
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
160+
ExposeNodeHeader bool `env:"LOCALAI_EXPOSE_NODE_HEADER" default:"false" help:"Set the X-LocalAI-Node response header on inference responses (OpenAI chat/completions/embeddings, Anthropic /v1/messages, Ollama /api/chat,/api/generate,/api/embed) with the ID of the worker that served the request. Disabled by default: the node ID reveals internal topology and should not be exposed on a public endpoint. Best-effort: under heavy concurrency the header may reflect a recent routing decision rather than this exact request's." group:"distributed"`
160161

161162
Version bool
162163

@@ -283,6 +284,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
283284
if r.AutoApproveNodes {
284285
opts = append(opts, config.EnableAutoApproveNodes)
285286
}
287+
if r.ExposeNodeHeader {
288+
opts = append(opts, config.WithExposeNodeHeader(true))
289+
}
286290

287291
if r.DisableMetricsEndpoint {
288292
opts = append(opts, config.DisableMetricsEndpoint)

core/config/application_config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@ type ApplicationConfig struct {
160160
// Distributed / Horizontal Scaling
161161
Distributed DistributedConfig
162162

163+
// ExposeNodeHeader, when true, activates middleware.ExposeNodeHeader on
164+
// the inference routes (OpenAI chat/completions/embeddings, Anthropic
165+
// /v1/messages, Ollama /api/chat,/api/generate,/api/embed). The
166+
// middleware wraps the response writer and attaches an "X-LocalAI-Node"
167+
// response header carrying the ID of the distributed-mode worker node
168+
// that served the request. Off by default because the node ID is
169+
// internal topology that can aid attacker reconnaissance if surfaced on
170+
// a public endpoint; operators opt in explicitly via
171+
// --expose-node-header / LOCALAI_EXPOSE_NODE_HEADER for debugging,
172+
// observability and load-balancer attribution.
173+
ExposeNodeHeader bool
174+
163175
// LocalAI Assistant chat modality. Hard-disable the in-process admin MCP
164176
// server with this flag; runtime-toggleable via /api/settings.
165177
DisableLocalAIAssistant bool
@@ -980,6 +992,15 @@ func WithDisableLocalAIAssistant(disabled bool) AppOption {
980992
}
981993
}
982994

995+
// WithExposeNodeHeader enables the X-LocalAI-Node response header on
996+
// inference endpoints. Default off; the node ID reveals internal cluster
997+
// topology and is opt-in for that reason.
998+
func WithExposeNodeHeader(enabled bool) AppOption {
999+
return func(o *ApplicationConfig) {
1000+
o.ExposeNodeHeader = enabled
1001+
}
1002+
}
1003+
9831004
// ToConfigLoaderOptions returns a slice of ConfigLoader Option.
9841005
// Some options defined at the application level are going to be passed as defaults for
9851006
// all the configuration for the models.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package middleware
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
9+
"github.com/labstack/echo/v4"
10+
11+
"github.com/mudler/LocalAI/core/config"
12+
"github.com/mudler/LocalAI/pkg/distributedhdr"
13+
)
14+
15+
// NodeHeaderName is the HTTP response header that, when --expose-node-header
16+
// is enabled, carries the ID of the distributed-mode worker node that served
17+
// the inference request. Off by default: node IDs reveal internal topology
18+
// and should not be exposed on a public endpoint.
19+
const NodeHeaderName = "X-LocalAI-Node"
20+
21+
// nodeHeaderWriter wraps an http.ResponseWriter and stamps the X-LocalAI-Node
22+
// header lazily on the first Write / WriteHeader / Flush call. The lazy
23+
// resolve is what makes this work for streaming: the picked node ID is only
24+
// known AFTER the router runs (i.e. on the first SSE chunk), so resolving at
25+
// request entry would attach the previous request's routing decision (or
26+
// nothing on a cold cache).
27+
type nodeHeaderWriter struct {
28+
http.ResponseWriter
29+
resolve func() string
30+
set bool
31+
}
32+
33+
func (w *nodeHeaderWriter) maybeSet() {
34+
if w.set {
35+
return
36+
}
37+
w.set = true
38+
if id := w.resolve(); id != "" {
39+
w.Header().Set(NodeHeaderName, id)
40+
}
41+
}
42+
43+
func (w *nodeHeaderWriter) Write(b []byte) (int, error) {
44+
w.maybeSet()
45+
return w.ResponseWriter.Write(b)
46+
}
47+
48+
func (w *nodeHeaderWriter) WriteHeader(code int) {
49+
w.maybeSet()
50+
w.ResponseWriter.WriteHeader(code)
51+
}
52+
53+
// Flush keeps SSE handlers working: Echo's Response.Flush goes through
54+
// http.NewResponseController which walks Unwrap() chains and invokes Flush
55+
// on the first wrapper that implements http.Flusher. By implementing it
56+
// here we both stamp the header before the underlying writer flushes AND
57+
// keep the streaming path alive.
58+
func (w *nodeHeaderWriter) Flush() {
59+
w.maybeSet()
60+
if f, ok := w.ResponseWriter.(http.Flusher); ok {
61+
f.Flush()
62+
}
63+
}
64+
65+
// Hijack preserves WebSocket / raw-conn handlers that need to take over the
66+
// underlying TCP connection (e.g. /v1/realtime). Without this the wrapper
67+
// would silently break those endpoints.
68+
//
69+
// When the underlying writer does not implement http.Hijacker we return
70+
// http.ErrNotSupported so callers using errors.Is (notably
71+
// http.NewResponseController.Hijack) detect the condition through the
72+
// standard sentinel rather than a string-matched custom error.
73+
func (w *nodeHeaderWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
74+
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
75+
return h.Hijack()
76+
}
77+
return nil, nil, fmt.Errorf("hijack not supported: %w", http.ErrNotSupported)
78+
}
79+
80+
// Unwrap lets http.NewResponseController reach through us to find optional
81+
// interfaces (CloseNotifier, SetReadDeadline, etc.) on the real writer.
82+
func (w *nodeHeaderWriter) Unwrap() http.ResponseWriter {
83+
return w.ResponseWriter
84+
}
85+
86+
// ExposeNodeHeader installs a per-request response writer wrapper that
87+
// stamps the X-LocalAI-Node header from the per-request holder published
88+
// by the distributed router on the first write. Off by default; opted in
89+
// via --expose-node-header / LOCALAI_EXPOSE_NODE_HEADER.
90+
//
91+
// Attribution is per-request correct: the middleware creates a fresh
92+
// holder per request, plumbs it through context.Context, and the router
93+
// writes the picked node ID for THIS request's routing decision. No
94+
// shared loader state, no overwriting across concurrent requests for the
95+
// same model on multiple replicas.
96+
func ExposeNodeHeader(appCfg *config.ApplicationConfig) echo.MiddlewareFunc {
97+
return func(next echo.HandlerFunc) echo.HandlerFunc {
98+
return func(c echo.Context) error {
99+
if appCfg == nil || !appCfg.ExposeNodeHeader {
100+
return next(c)
101+
}
102+
103+
// One holder per request. The pointer is captured both in
104+
// the wrapper closure (read side) and in the request
105+
// context (write side, accessed by the router via
106+
// distributedhdr.Stamp). Both sides point at the same
107+
// atomic slot.
108+
holder := distributedhdr.NewHolder()
109+
110+
req := c.Request()
111+
c.SetRequest(req.WithContext(distributedhdr.WithHolder(req.Context(), holder)))
112+
113+
orig := c.Response().Writer
114+
wrapper := &nodeHeaderWriter{
115+
ResponseWriter: orig,
116+
resolve: func() string {
117+
return distributedhdr.Load(holder)
118+
},
119+
}
120+
c.Response().Writer = wrapper
121+
defer func() {
122+
c.Response().Writer = orig
123+
}()
124+
return next(c)
125+
}
126+
}
127+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package middleware
2+
3+
// Regression coverage for the multi-replica X-LocalAI-Node attribution
4+
// bug fixed by this PR.
5+
//
6+
// Pre-refactor failure mode: ExposeNodeHeader resolved the node ID by
7+
// calling ml.LookupNodeID(modelName), which read a single per-modelID
8+
// slot in the loader's in-memory store. The distributed router
9+
// overwrote that slot on every routing decision, so when N concurrent
10+
// requests for the same model were routed to N different replicas, the
11+
// header value the wrapper picked up at first-byte time depended on
12+
// goroutine interleaving and not on which replica THIS request was
13+
// actually sent to.
14+
//
15+
// The fix routes attribution through a per-request atomic holder
16+
// installed by the middleware and stamped by the router via
17+
// distributedhdr.Stamp. Each request carries its own slot, so peer
18+
// stamps cannot bleed in.
19+
//
20+
// This spec exercises the exact concurrency pattern the bug required to
21+
// reproduce: many goroutines, all running through the same middleware
22+
// instance (mirroring e.Use() in production), each stamping a distinct
23+
// node ID, each asserting its own response carries the matching ID.
24+
25+
import (
26+
"fmt"
27+
"net/http"
28+
"net/http/httptest"
29+
"sync"
30+
31+
"github.com/labstack/echo/v4"
32+
. "github.com/onsi/ginkgo/v2"
33+
. "github.com/onsi/gomega"
34+
35+
"github.com/mudler/LocalAI/core/config"
36+
"github.com/mudler/LocalAI/pkg/distributedhdr"
37+
)
38+
39+
var _ = Describe("ExposeNodeHeader multi-replica attribution", func() {
40+
It("each concurrent request sees the node ID stamped on ITS OWN request, not a peer's", func() {
41+
appCfg := &config.ApplicationConfig{ExposeNodeHeader: true}
42+
e := echo.New()
43+
mw := ExposeNodeHeader(appCfg)
44+
45+
const N = 64
46+
results := make([]string, N)
47+
var wg sync.WaitGroup
48+
wg.Add(N)
49+
50+
// Drive N concurrent requests through the same middleware
51+
// instance. Each handler stamps a distinct, per-request node ID
52+
// derived from the request index, then yields before writing
53+
// the body so the goroutines have ample opportunity to
54+
// interleave. Under the old shared-loader design this is the
55+
// configuration that surfaced the bug; under the new
56+
// per-request-holder design every request must round-trip its
57+
// own stamp.
58+
for i := 0; i < N; i++ {
59+
i := i
60+
go func() {
61+
defer wg.Done()
62+
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
63+
rec := httptest.NewRecorder()
64+
c := e.NewContext(req, rec)
65+
66+
expected := fmt.Sprintf("node-%d", i)
67+
handler := func(c echo.Context) error {
68+
distributedhdr.Stamp(c.Request().Context(), expected)
69+
// Yield to amplify interleaving: if any stamp were
70+
// shared across requests, the late writes would
71+
// observe peer-stamped values instead of their
72+
// own.
73+
for j := 0; j < 16; j++ {
74+
_ = j
75+
}
76+
return c.String(http.StatusOK, "ok")
77+
}
78+
Expect(mw(handler)(c)).To(Succeed())
79+
results[i] = rec.Header().Get(NodeHeaderName)
80+
}()
81+
}
82+
wg.Wait()
83+
84+
for i := 0; i < N; i++ {
85+
Expect(results[i]).To(Equal(fmt.Sprintf("node-%d", i)),
86+
"request %d must see ITS OWN routing decision in the header, not a peer's", i)
87+
}
88+
})
89+
})

0 commit comments

Comments
 (0)