mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-01 02:49:51 -04:00
* feat(distributed): add per-request node ID context holder Introduce pkg/distributedhdr, a leaf package carrying a per-request *atomic.Value holder for the picked worker node ID from the SmartRouter (core/services/nodes) up to the HTTP response writer wrapper (core/http/middleware). Avoids the import cycle that a shared key in either consumer would create. Exposes NewHolder, WithHolder, Holder, Stamp, Load, Inherit. The holder is atomic.Value so cross-goroutine publish from the router to the response writer wrapper is race-clean. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * 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> * feat(distributed): stamp node ID in router and wire middleware to inference routes ModelRouterAdapter.Route stamps the picked node ID into the per-request holder via distributedhdr.Stamp(ctx, result.Node.ID) right after replica selection. Wire ExposeNodeHeader middleware to: - OpenAI chat/completion/embeddings + audio transcriptions/speech + image generations/inpainting - Anthropic /v1/messages - Ollama /api/chat, /api/generate, /api/embed, /api/embeddings - Jina /v1/rerank - LocalAI /v1/vad The middleware's wrapper reads the holder on first byte and sets the X-LocalAI-Node response header before delegating to the underlying writer. Per-request scope means no race under concurrent multi-replica routing. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): thread request context through backend Load + cover ctx propagation Five non-OpenAI backend helpers were silently using app.Context instead of the request context for the gRPC backend call: transcription, TTS, image generation, rerank, VAD. Effect: distributedhdr.Stamp in the router callback was a silent no-op for these paths, AND client cancellation didn't propagate to in-flight inference. Thread c.Request().Context() (or the equivalent input.Context after the request middleware has installed the correlation-ID derived context) through each helper and into ModelOptions via model.WithContext(ctx). ImageGeneration's signature gains a leading ctx parameter; in-tree callers (openai image, openai inpainting, openai inpainting_test) are updated to match. ModelEmbedding gains a leading ctx parameter for the same reason; the openai and ollama embedding handlers pass the request context through. chat_stream_workers.go defers the initial role=assistant chunk emission until the first token callback so the wrapper's lazy X-LocalAI-Node lookup against the loader runs AFTER ml.Load has stamped the per-modelID node ID; semantically identical for clients (role still arrives before any text). Regression test core/backend/ctx_propagation_test.go pins ctx propagation for all five helpers. Docs updated to enumerate the full endpoint coverage of the --expose-node-header flag. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
170 lines
6.6 KiB
Go
170 lines
6.6 KiB
Go
package backend_test
|
|
|
|
// Regression spec for X-LocalAI-Node coverage on audio/image/TTS/rerank/VAD.
|
|
//
|
|
// The X-LocalAI-Node middleware (core/http/middleware.ExposeNodeHeader)
|
|
// works end-to-end only if the per-request holder attached to the HTTP
|
|
// request context reaches the SmartRouter via ml.Load(opts...). The chain
|
|
// is:
|
|
//
|
|
// handler -> backend.Foo(ctx, ...) -> ModelOptions(cfg, app, WithContext(ctx))
|
|
// -> ml.Load(opts...) -> grpcModel(..., o.context) -> modelRouter(ctx, ...)
|
|
// -> SmartRouter -> distributedhdr.Stamp(ctx, nodeID)
|
|
//
|
|
// If any backend helper drops `ctx` and lets ModelOptions fall back to the
|
|
// app context, the router never sees the per-request holder and the
|
|
// header silently stays empty for that endpoint. These specs pin the
|
|
// request-context-reaches-router contract for the five backend helpers
|
|
// that were previously dropping ctx between the handler and Load.
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
|
|
"github.com/mudler/LocalAI/core/backend"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
pbproto "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/LocalAI/pkg/distributedhdr"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// newCapturingLoader returns a ModelLoader wired with a stub model router
|
|
// that captures the context it receives and then short-circuits with a
|
|
// sentinel error. The router callback is the exact seam where the
|
|
// SmartRouter would call distributedhdr.Stamp in production, so observing
|
|
// the holder here is equivalent to observing it at the real router.
|
|
func newCapturingLoader() (*model.ModelLoader, *atomic.Value, func() context.Context) {
|
|
loader := model.NewModelLoader(&system.SystemState{})
|
|
var captured atomic.Value
|
|
loader.SetModelRouter(func(ctx context.Context, _ string, _, _, _ string, _ *pbproto.ModelOptions, _ bool) (*model.Model, error) {
|
|
captured.Store(ctx)
|
|
// Return an error so the backend short-circuits before trying to
|
|
// dial gRPC. We only care about the context-arrival contract.
|
|
return nil, errRouterShortCircuit
|
|
})
|
|
get := func() context.Context {
|
|
v, _ := captured.Load().(context.Context)
|
|
return v
|
|
}
|
|
return loader, &captured, get
|
|
}
|
|
|
|
var errRouterShortCircuit = sentinelErr("router short-circuit (test)")
|
|
|
|
type sentinelErr string
|
|
|
|
func (s sentinelErr) Error() string { return string(s) }
|
|
|
|
func newAppCfg() *config.ApplicationConfig {
|
|
return config.NewApplicationConfig(config.WithSystemState(&system.SystemState{}))
|
|
}
|
|
|
|
func newModelCfg() config.ModelConfig {
|
|
threads := 1
|
|
cfg := config.ModelConfig{
|
|
Name: "test-model",
|
|
Backend: "stub-backend",
|
|
Threads: &threads,
|
|
}
|
|
cfg.Model = "test.bin"
|
|
return cfg
|
|
}
|
|
|
|
var _ = Describe("X-LocalAI-Node ctx propagation contract", func() {
|
|
const fakeNodeID = "node-ctx-propagation-7"
|
|
|
|
var (
|
|
appCfg *config.ApplicationConfig
|
|
modelCfg config.ModelConfig
|
|
loader *model.ModelLoader
|
|
routerCtxOf func() context.Context
|
|
holder *atomic.Value
|
|
reqCtx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
appCfg = newAppCfg()
|
|
modelCfg = newModelCfg()
|
|
loader, _, routerCtxOf = newCapturingLoader()
|
|
holder = distributedhdr.NewHolder()
|
|
reqCtx = distributedhdr.WithHolder(context.Background(), holder)
|
|
})
|
|
|
|
// stampViaRouterCtx asserts the captured router context carries the
|
|
// SAME holder that was attached to the request. We verify by stamping
|
|
// through the router-side ctx and observing the value via the
|
|
// request-side holder; if the holders were different objects the load
|
|
// would return "".
|
|
stampViaRouterCtx := func() {
|
|
routerCtx := routerCtxOf()
|
|
Expect(routerCtx).ToNot(BeNil(), "router callback must have been invoked")
|
|
distributedhdr.Stamp(routerCtx, fakeNodeID)
|
|
Expect(distributedhdr.Load(holder)).To(Equal(fakeNodeID),
|
|
"stamp via router-side ctx must be observable via the request-side holder")
|
|
}
|
|
|
|
It("Rerank forwards the request context to the SmartRouter", func() {
|
|
_, err := backend.Rerank(reqCtx, &pbproto.RerankRequest{Query: "q"}, loader, appCfg, modelCfg)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("VAD forwards the request context to the SmartRouter", func() {
|
|
_, err := backend.VAD(&schema.VADRequest{}, reqCtx, loader, appCfg, modelCfg)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("ModelTTS forwards the request context to the SmartRouter", func() {
|
|
_, _, err := backend.ModelTTS(reqCtx, "hello", "", "", loader, appCfg, modelCfg)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("ModelTTSStream forwards the request context to the SmartRouter", func() {
|
|
err := backend.ModelTTSStream(reqCtx, "hello", "", "", loader, appCfg, modelCfg, func([]byte) error { return nil })
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("ModelTranscriptionWithOptions forwards the request context to the SmartRouter", func() {
|
|
_, err := backend.ModelTranscriptionWithOptions(reqCtx, backend.TranscriptionRequest{Audio: "x.wav"}, loader, modelCfg, appCfg)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("ModelTranscriptionStream forwards the request context to the SmartRouter", func() {
|
|
err := backend.ModelTranscriptionStream(reqCtx, backend.TranscriptionRequest{Audio: "x.wav"}, loader, modelCfg, appCfg, func(backend.TranscriptionStreamChunk) {})
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("ImageGeneration forwards the request context to the SmartRouter", func() {
|
|
_, err := backend.ImageGeneration(reqCtx, 64, 64, 1, 0, "p", "", "", "/tmp/out.png", loader, modelCfg, appCfg, nil)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("router short-circuit (test)"))
|
|
stampViaRouterCtx()
|
|
})
|
|
|
|
It("does NOT leak the holder when the app context is used instead", func() {
|
|
// Sanity: the bug being fixed manifests as the router getting
|
|
// appCfg.Context (no holder) instead of reqCtx (holder). A direct
|
|
// call with context.Background() must not see the holder via the
|
|
// app context surface.
|
|
appCtxOnly := appCfg.Context
|
|
Expect(distributedhdr.Holder(appCtxOnly)).To(BeNil(),
|
|
"the app context must not be the carrier of per-request holders")
|
|
})
|
|
})
|