mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 17:44:30 -04:00
* docs: design configurable copy buffering Document the context-aware copy buffer option and its validation plan. Assisted-by: Codex:gpt-5 * docs: design durable distributed staging operations Assisted-by: Codex:gpt-5 * docs: design distributed model config revisions Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] * feat(config): add stable model revisions Hash typed model configuration and effective protobuf options deterministically for distributed revision comparisons. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] * feat(worker): acknowledge exact model stops Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] * feat(nodes): track model config revisions Assisted-by: Codex:GPT-5 [apply_patch] * fix(distributed): retry quarantined model cleanup Stop quarantined replicas by exact process identity, retain failed cleanup as durable capped retries, and compare-and-delete only the claimed registry row. Process one sufficiently leased row at a time so multiple frontends cannot duplicate slow cleanup work. Assisted-by: Codex:gpt-5 * fix(distributed): bind loads to config revisions Assisted-by: Codex: GPT-5 [OpenAI Codex] * fix(modeladmin): apply config revisions consistently Route model edits, patches, state changes, deletion, and peer refreshes through the same revision lifecycle. Quarantine stale replicas before exact cleanup and report durable pending cleanup without failing successful config writes. Assisted-by: Codex: GPT-5 [OpenAI Codex] * feat(distributed): expose model config revision state Document replica revision observability and durable cleanup behavior. Keep pending cleanup explicit in model mutation responses and verify endpoint contracts expose revision state without serialized load options. Assisted-by: Codex:GPT-5 [OpenAI Codex] * test(distributed): cover model revision convergence Exercise cross-frontend quarantine, stale replay rejection, exact cleanup retry, worker re-registration, and current-generation replica convergence against the distributed PostgreSQL harness. Assisted-by: Codex:gpt-5 * fix(distributed): pass config revision CI checks Keep configured gallery sources out of authoritative runtime snapshots only after validating their real schema, and harden rollback snapshots against symlink races and non-regular files. Assisted-by: Codex: GPT-5 [OpenAI Codex] --------- 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"
|
|
"github.com/mudler/LocalAI/pkg/distributedhdr"
|
|
pbproto "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"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", "", "", "", nil, 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", "", "", "", nil, 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")
|
|
})
|
|
})
|