mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 01:54:31 -04:00
Re-review round 2. One blocking defect, and it was the concern I filed myself last round and mis-scoped as a future trap. It was live, and it sat on the most destructive reaping path of the five. RouteResult.Client is an InFlightTrackingClient, over a FileStagingClient when a stager is configured. model_router puts that on the cached remote model and pkg/model's checkIsLoaded asks IT whether the transport failed. Both wrappers embed grpc.Backend, which does not declare LastDialError, so the type assertion read nil and the guard added last round fell straight through to the old eviction. That eviction sends backend.stop over NATS to every node holding the model and deletes every replica row, where the other sites delete one. The spec covering it built a bare client by hand, which is why it passed while production did not. This is the third time in this task a correct fix was disarmed one layer out, so the fix is a mechanism rather than two methods. BackendUnwrapper is one line per decorator, LastDialErrorOf walks the chain, and both consumers now call it instead of each keeping its own assertion. One implementation, no per-caller policy to get wrong. Sweeping every type that embeds or holds a grpc.Backend found a third decorator the review had not named, and it is itself a reaping consumer of the same collapsed signal. ConnectionEvictingClient is built for remote models in initializers.go and its evict callback runs ShutdownModel; it fires during INFERENCE rather than on a health check, so a tunnel blip mid-request was enough to stop a model that was loaded and serving. It consults the transport first now. A locally spawned backend has no custom transport, so that path is unchanged byte for byte. Everything else touching a Backend is a consumer rather than a decorator; there is no fourth. The probe cache joiner shape is pinned. It was the right design last round with nothing holding it: the mutation back to a closed-over variable passed all 602 specs in the package. Eight goroutines coalesced on a probe that blocks on a channel now assert every joiner gets the leader's REASON and not just its answer, which is the difference between a leader declining to reap and its seven joiners reaping on the leader's own observation. The LastDialError scope note claimed an exactness it does not have at checkIsLoaded, which reads a shared long-lived client after releasing opMutex. It now says which caller is not exact, why the imprecision is accepted there, and what making it exact would cost. The four-outcome table in the docs still said a worker with no live owner is treated as absent and rescheduled, contradicting the code and the paragraph nine lines below it. None of those outcomes is absence any more, and the table says so, names the fifth, and points at the heartbeat as the thing that does decide presence. Five mutations, each reddening named specs, including the two the reviewer found surviving. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
243 lines
13 KiB
Go
243 lines
13 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
var embeds = map[string]*embedBackend{}
|
|
|
|
func Provide(addr string, llm AIModel) {
|
|
embeds[addr] = &embedBackend{s: &server{llm: llm}}
|
|
}
|
|
|
|
func NewClient(address string, parallel bool, wd WatchDog, enableWatchDog bool) Backend {
|
|
if bc, ok := embeds[address]; ok {
|
|
return bc
|
|
}
|
|
return buildClient(address, parallel, wd, enableWatchDog, "")
|
|
}
|
|
|
|
// NewClientWithToken creates a gRPC client that sends a bearer token with every call.
|
|
// Used in distributed mode to authenticate with remote backend processes.
|
|
func NewClientWithToken(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string) Backend {
|
|
if bc, ok := embeds[address]; ok {
|
|
return bc
|
|
}
|
|
return buildClient(address, parallel, wd, enableWatchDog, token)
|
|
}
|
|
|
|
// NewClientWithDialer creates a gRPC client that reaches its backend through
|
|
// dialer rather than by connecting to address.
|
|
//
|
|
// It is what distributed mode uses to reach a backend process on a worker: the
|
|
// worker holds one multiplexed tunnel to a frontend replica and listens on
|
|
// nothing, so address names which backend process the stream is for and the
|
|
// dialer decides how the stream gets there. A nil dialer is a programming
|
|
// error on this path rather than a fallback, because falling back to a direct
|
|
// dial would work in a single-replica test and fail in production; callers with
|
|
// no dialer call NewClientWithToken and mean it.
|
|
func NewClientWithDialer(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string, dialer func(ctx context.Context, addr string) (net.Conn, error)) Backend {
|
|
if bc, ok := embeds[address]; ok {
|
|
return bc
|
|
}
|
|
// Assigned on the concrete type rather than through a checked assertion:
|
|
// an assertion that failed would silently hand back a client that dials
|
|
// the address directly, which is the exact bypass this constructor exists
|
|
// to close.
|
|
c := buildClient(address, parallel, wd, enableWatchDog, token)
|
|
// Wrapped rather than stored bare, so every dial outcome is recorded. This
|
|
// is the seam that carries the reason a dial failed past gRPC, which
|
|
// flattens it into codes.Unavailable; see (*Client).LastDialError.
|
|
c.dialer = func(ctx context.Context, addr string) (net.Conn, error) {
|
|
conn, err := dialer(ctx, addr)
|
|
c.recordDialErr(err)
|
|
return conn, err
|
|
}
|
|
return c
|
|
}
|
|
|
|
// DialErrorReporter is implemented by a Backend that reaches its process
|
|
// through a custom transport and can say whether that transport, rather than
|
|
// the process, is what failed.
|
|
//
|
|
// It is a separate interface and NOT part of Backend on purpose: only the
|
|
// handful of callers that act on the difference need it, and widening Backend
|
|
// would make every wrapper and every test double implement a method they have
|
|
// no answer for.
|
|
type DialErrorReporter interface {
|
|
LastDialError() error
|
|
}
|
|
|
|
// BackendUnwrapper is implemented by a Backend that DECORATES another one.
|
|
//
|
|
// Every wrapper in this codebase must implement it, and the reason is a defect
|
|
// that shipped: a wrapper embeds the Backend interface, so it inherits every
|
|
// declared method and NOTHING else. DialErrorReporter is deliberately not
|
|
// declared on Backend, so a wrapped client silently stopped answering "did the
|
|
// transport fail" and the guard built on that answer read nil in production
|
|
// while passing every spec that constructed a raw client by hand.
|
|
//
|
|
// Implementing this is what makes a decorator transparent to LastDialErrorOf,
|
|
// and it is one line rather than a re-implementation per wrapper, so there is
|
|
// no per-wrapper policy to get wrong.
|
|
type BackendUnwrapper interface {
|
|
Unwrap() Backend
|
|
}
|
|
|
|
// maxBackendUnwrapDepth bounds the walk below. Three wrappers exist today and
|
|
// they nest at most two deep; the bound is a guard against a cycle a future
|
|
// wrapper could introduce, not a limit anything real approaches.
|
|
const maxBackendUnwrapDepth = 16
|
|
|
|
// LastDialErrorOf reports why the most recent dial under b failed, looking
|
|
// THROUGH any decorators, or nil when the dial succeeded or nothing under b has
|
|
// a custom transport.
|
|
//
|
|
// It is the single implementation of that question. Its callers
|
|
// (core/services/nodes and pkg/model) each had their own type assertion, and an
|
|
// assertion cannot see past a wrapper: in production the client handed to
|
|
// pkg/model is an *InFlightTrackingClient over a *FileStagingClient over the
|
|
// real one, so both callers were asking a wrapper that had no answer and
|
|
// reading nil as "the transport was fine".
|
|
func LastDialErrorOf(b Backend) error {
|
|
for range maxBackendUnwrapDepth {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
if reporter, ok := b.(DialErrorReporter); ok {
|
|
return reporter.LastDialError()
|
|
}
|
|
wrapper, ok := b.(BackendUnwrapper)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
b = wrapper.Unwrap()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildClient(address string, parallel bool, wd WatchDog, enableWatchDog bool, token string) *Client {
|
|
if !enableWatchDog {
|
|
wd = nil
|
|
}
|
|
return &Client{
|
|
address: address,
|
|
parallel: parallel,
|
|
wd: wd,
|
|
token: token,
|
|
}
|
|
}
|
|
|
|
// Backend is the full client surface of a model backend. It is deliberately
|
|
// composed of two sub-interfaces so that wrappers can get a COMPILE-TIME
|
|
// guarantee about which methods they must account for:
|
|
//
|
|
// - InferenceBackend - methods that each perform one discrete inference call
|
|
// (the call begins on entry and ends on return). A wrapper that does
|
|
// per-call accounting - e.g. the distributed router's in-flight tracker,
|
|
// core/services/nodes.InFlightTrackingClient - embeds only ControlBackend
|
|
// and implements every InferenceBackend method explicitly. Adding a method
|
|
// to InferenceBackend therefore breaks that wrapper's build until it is
|
|
// implemented: inference can't be added without an accounting decision.
|
|
// - ControlBackend - everything that is NOT a discrete inference call:
|
|
// lifecycle/control-plane operations and the streaming constructors whose
|
|
// work spans the returned stream rather than the constructor call. These
|
|
// are safe to pass through untracked.
|
|
//
|
|
// Keep the two sets disjoint; every backend method belongs to exactly one.
|
|
type Backend interface {
|
|
InferenceBackend
|
|
ControlBackend
|
|
}
|
|
|
|
// InferenceBackend is the subset of Backend whose methods each map to a single
|
|
// inference call. Wrappers that account for in-flight work must implement these
|
|
// explicitly (see Backend). Do NOT add methods that return a stream client or
|
|
// that are control-plane only - those belong in ControlBackend.
|
|
type InferenceBackend interface {
|
|
Embeddings(ctx context.Context, in *pb.PredictOptions, opts ...grpc.CallOption) (*pb.EmbeddingResult, error)
|
|
PredictStream(ctx context.Context, in *pb.PredictOptions, f func(reply *pb.Reply), opts ...grpc.CallOption) error
|
|
Predict(ctx context.Context, in *pb.PredictOptions, opts ...grpc.CallOption) (*pb.Reply, error)
|
|
GenerateImage(ctx context.Context, in *pb.GenerateImageRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
Animate3D(ctx context.Context, in *pb.Animate3DRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
TTS(ctx context.Context, in *pb.TTSRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
TTSStream(ctx context.Context, in *pb.TTSRequest, f func(reply *pb.Reply), opts ...grpc.CallOption) error
|
|
SoundGeneration(ctx context.Context, in *pb.SoundGenerationRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
AudioTranscription(ctx context.Context, in *pb.TranscriptRequest, opts ...grpc.CallOption) (*pb.TranscriptResult, error)
|
|
AudioTranscriptionStream(ctx context.Context, in *pb.TranscriptRequest, f func(chunk *pb.TranscriptStreamResponse), opts ...grpc.CallOption) error
|
|
Detect(ctx context.Context, in *pb.DetectOptions, opts ...grpc.CallOption) (*pb.DetectResponse, error)
|
|
Depth(ctx context.Context, in *pb.DepthRequest, opts ...grpc.CallOption) (*pb.DepthResponse, error)
|
|
FaceVerify(ctx context.Context, in *pb.FaceVerifyRequest, opts ...grpc.CallOption) (*pb.FaceVerifyResponse, error)
|
|
FaceAnalyze(ctx context.Context, in *pb.FaceAnalyzeRequest, opts ...grpc.CallOption) (*pb.FaceAnalyzeResponse, error)
|
|
VoiceVerify(ctx context.Context, in *pb.VoiceVerifyRequest, opts ...grpc.CallOption) (*pb.VoiceVerifyResponse, error)
|
|
VoiceAnalyze(ctx context.Context, in *pb.VoiceAnalyzeRequest, opts ...grpc.CallOption) (*pb.VoiceAnalyzeResponse, error)
|
|
VoiceEmbed(ctx context.Context, in *pb.VoiceEmbedRequest, opts ...grpc.CallOption) (*pb.VoiceEmbedResponse, error)
|
|
Rerank(ctx context.Context, in *pb.RerankRequest, opts ...grpc.CallOption) (*pb.RerankResult, error)
|
|
TokenClassify(ctx context.Context, in *pb.TokenClassifyRequest, opts ...grpc.CallOption) (*pb.TokenClassifyResponse, error)
|
|
Score(ctx context.Context, in *pb.ScoreRequest, opts ...grpc.CallOption) (*pb.ScoreResponse, error)
|
|
VAD(ctx context.Context, in *pb.VADRequest, opts ...grpc.CallOption) (*pb.VADResponse, error)
|
|
Diarize(ctx context.Context, in *pb.DiarizeRequest, opts ...grpc.CallOption) (*pb.DiarizeResponse, error)
|
|
SoundDetection(ctx context.Context, in *pb.SoundDetectionRequest, opts ...grpc.CallOption) (*pb.SoundDetectionResponse, error)
|
|
AudioEncode(ctx context.Context, in *pb.AudioEncodeRequest, opts ...grpc.CallOption) (*pb.AudioEncodeResult, error)
|
|
AudioDecode(ctx context.Context, in *pb.AudioDecodeRequest, opts ...grpc.CallOption) (*pb.AudioDecodeResult, error)
|
|
AudioTransform(ctx context.Context, in *pb.AudioTransformRequest, opts ...grpc.CallOption) (*pb.AudioTransformResult, error)
|
|
}
|
|
|
|
// ControlBackend is the subset of Backend that is NOT per-call inference:
|
|
// lifecycle/control-plane operations and the streaming constructors whose work
|
|
// spans the returned stream rather than the constructor call. In-flight-tracking
|
|
// wrappers embed this directly and pass it through untracked (see Backend).
|
|
type ControlBackend interface {
|
|
IsBusy() bool
|
|
HealthCheck(ctx context.Context) (bool, error)
|
|
LoadModel(ctx context.Context, in *pb.ModelOptions, opts ...grpc.CallOption) (*pb.Result, error)
|
|
TokenizeString(ctx context.Context, in *pb.PredictOptions, opts ...grpc.CallOption) (*pb.TokenizationResponse, error)
|
|
Detokenize(ctx context.Context, in *pb.DetokenizeRequest, opts ...grpc.CallOption) (*pb.DetokenizeResponse, error)
|
|
Status(ctx context.Context) (*pb.StatusResponse, error)
|
|
|
|
StoresSet(ctx context.Context, in *pb.StoresSetOptions, opts ...grpc.CallOption) (*pb.Result, error)
|
|
StoresDelete(ctx context.Context, in *pb.StoresDeleteOptions, opts ...grpc.CallOption) (*pb.Result, error)
|
|
StoresGet(ctx context.Context, in *pb.StoresGetOptions, opts ...grpc.CallOption) (*pb.StoresGetResult, error)
|
|
StoresFind(ctx context.Context, in *pb.StoresFindOptions, opts ...grpc.CallOption) (*pb.StoresFindResult, error)
|
|
|
|
GetTokenMetrics(ctx context.Context, in *pb.MetricsRequest, opts ...grpc.CallOption) (*pb.MetricsResponse, error)
|
|
|
|
// Streaming constructors: these return a stream client immediately; the
|
|
// actual inference spans the stream's lifetime, not this call, so they are
|
|
// NOT tracked as a single in-flight unit.
|
|
AudioTransformStream(ctx context.Context, opts ...grpc.CallOption) (AudioTransformStreamClient, error)
|
|
AudioToAudioStream(ctx context.Context, opts ...grpc.CallOption) (AudioToAudioStreamClient, error)
|
|
AudioTranscriptionLive(ctx context.Context, opts ...grpc.CallOption) (AudioTranscriptionLiveClient, error)
|
|
|
|
// Forward proxies a raw HTTP request to an upstream provider for
|
|
// passthrough-mode cloud-proxy backends. Caller streams a single
|
|
// ForwardRequest carrying path/method/headers/body, then closes
|
|
// send; backend streams back status/headers in the first reply
|
|
// and body chunks thereafter.
|
|
Forward(ctx context.Context, opts ...grpc.CallOption) (ForwardClient, error)
|
|
|
|
ModelMetadata(ctx context.Context, in *pb.ModelOptions, opts ...grpc.CallOption) (*pb.ModelMetadataResponse, error)
|
|
|
|
// Fine-tuning
|
|
StartFineTune(ctx context.Context, in *pb.FineTuneRequest, opts ...grpc.CallOption) (*pb.FineTuneJobResult, error)
|
|
FineTuneProgress(ctx context.Context, in *pb.FineTuneProgressRequest, f func(update *pb.FineTuneProgressUpdate), opts ...grpc.CallOption) error
|
|
StopFineTune(ctx context.Context, in *pb.FineTuneStopRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
ListCheckpoints(ctx context.Context, in *pb.ListCheckpointsRequest, opts ...grpc.CallOption) (*pb.ListCheckpointsResponse, error)
|
|
ExportModel(ctx context.Context, in *pb.ExportModelRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
|
|
// Quantization
|
|
StartQuantization(ctx context.Context, in *pb.QuantizationRequest, opts ...grpc.CallOption) (*pb.QuantizationJobResult, error)
|
|
QuantizationProgress(ctx context.Context, in *pb.QuantizationProgressRequest, f func(update *pb.QuantizationProgressUpdate), opts ...grpc.CallOption) error
|
|
StopQuantization(ctx context.Context, in *pb.QuantizationStopRequest, opts ...grpc.CallOption) (*pb.Result, error)
|
|
|
|
// Free releases GPU/model resources (e.g. VRAM) without stopping the process.
|
|
Free(ctx context.Context) error
|
|
}
|