mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
* feat(proxy): accept direct upstream API keys Support direct credentials for models created in the UI. Keep env and file references as mutually exclusive alternatives. Use model env for frontend probes and omit credentials from structured config logs. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): isolate model environments in worker processes Forward complete model environment snapshots through backend installation. Restart worker children when overrides change or are removed, and include keyed snapshot identities in frontend install coalescing. Cover wire compatibility, routing, real worker children, and option copying; document rollout requirements and control-transport security. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(worker): serialize installs by model replica Different backend names can target the same process slot. Hold a slot lock through startup, readiness, and operation attachment so concurrent installs cannot reuse another configuration or an unready child. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(nodes): update endpoint stub for model env Keep the endpoint command stub aligned with the environment-aware install interface. Assert interface conformance so future changes fail at the stub declaration rather than at endpoint call sites. Assisted-by: nib:gpt-6.1-sol 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>
263 lines
14 KiB
Go
263 lines
14 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
grpc "github.com/mudler/LocalAI/pkg/grpc"
|
|
)
|
|
|
|
type ExactModelStopper interface {
|
|
StopModelReplica(ctx context.Context, nodeID string, replica NodeModel, force bool) (workerctl.ModelStopReply, error)
|
|
}
|
|
|
|
type ModelCleanupRegistry interface {
|
|
ClaimModelCleanupRetries(ctx context.Context, now, leaseUntil time.Time, limit int) ([]NodeModel, error)
|
|
RecordModelCleanupFailure(ctx context.Context, nodeID, modelName string, replicaIndex int, cleanupErr string, nextRetry time.Time) error
|
|
RemoveClaimedModelCleanup(ctx context.Context, replica NodeModel) (bool, error)
|
|
}
|
|
|
|
// ModelRouter is used by SmartRouter for routing decisions and model lifecycle.
|
|
type ModelRouter interface {
|
|
FindAndLockNodeWithModel(ctx context.Context, modelName string, candidateNodeIDs []string, pref *RoutePreference) (*BackendNode, *NodeModel, error)
|
|
DecrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
IncrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
TouchNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int)
|
|
SetNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int, state, address string, initialInFlight int) error
|
|
SetNodeModelRevision(ctx context.Context, nodeID, modelName string, replicaIndex int, state, address string, initialInFlight int, revision, effectiveOptionsHash string) error
|
|
SetNodeModelLoadInfo(ctx context.Context, nodeID, modelName string, replicaIndex int, backendType string, optsBlob []byte) error
|
|
SetNodeModelLoadInfoRevision(ctx context.Context, nodeID, modelName string, replicaIndex int, backendType, revision string, optsBlob []byte) error
|
|
UpsertModelLoadInfo(ctx context.Context, modelName, backendType string, optsBlob []byte) error
|
|
UpsertModelLoadInfoRevision(ctx context.Context, modelName, backendType, revision string, optsBlob []byte) error
|
|
GetModelLoadInfo(ctx context.Context, modelName string) (backendType string, optsBlob []byte, err error)
|
|
GetModelLoadInfoRevision(ctx context.Context, modelName string) (backendType, revision string, optsBlob []byte, err error)
|
|
AdvanceModelConfigRevision(ctx context.Context, modelName, revision string) ([]NodeModel, error)
|
|
EstablishModelConfigRevision(ctx context.Context, modelName, revision string) error
|
|
GetModelConfigRevision(ctx context.Context, modelName string) (string, error)
|
|
GetNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) (*NodeModel, error)
|
|
RecordModelCleanupFailure(ctx context.Context, nodeID, modelName string, replicaIndex int, cleanupErr string, nextRetry time.Time) error
|
|
ListModelCleanupRetries(ctx context.Context, now time.Time, limit int) ([]NodeModel, error)
|
|
NextFreeReplicaIndex(ctx context.Context, nodeID, modelName string, maxSlots int) (int, error)
|
|
CountReplicasOnNode(ctx context.Context, nodeID, modelName string) (int, error)
|
|
FindNodeWithVRAM(ctx context.Context, minBytes uint64) (*BackendNode, error)
|
|
FindIdleNode(ctx context.Context) (*BackendNode, error)
|
|
FindLeastLoadedNode(ctx context.Context) (*BackendNode, error)
|
|
FindGlobalLRUModelWithZeroInFlight(ctx context.Context) (*NodeModel, error)
|
|
FindLRUModel(ctx context.Context, nodeID string, excludeModels []string) (*NodeModel, error)
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
GetModelScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error)
|
|
GetGoverningScheduling(ctx context.Context, modelName string) (*ModelSchedulingConfig, error)
|
|
FindNodesBySelector(ctx context.Context, selector map[string]string) ([]BackendNode, error)
|
|
FindNodesWithFreeSlot(ctx context.Context, modelName string, candidateNodeIDs []string) ([]BackendNode, error)
|
|
NarrowByDiskHeadroom(ctx context.Context, candidateNodeIDs []string, required uint64) ([]string, error)
|
|
ReserveVRAM(ctx context.Context, nodeID string, bytes uint64) error
|
|
ReleaseVRAM(ctx context.Context, nodeID string, bytes uint64) error
|
|
FindNodeWithVRAMFromSet(ctx context.Context, minBytes uint64, nodeIDs []string) (*BackendNode, error)
|
|
FindIdleNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
|
|
FindLeastLoadedNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
|
|
GetNodeLabels(ctx context.Context, nodeID string) ([]NodeLabel, error)
|
|
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
|
|
LoadedReplicaStats(ctx context.Context, modelName string, candidateNodeIDs []string) ([]ReplicaCandidate, error)
|
|
MarkUnhealthy(ctx context.Context, nodeID string) error
|
|
LoadJobStore
|
|
}
|
|
|
|
// LoadJobStore is the durable cold-load job record SmartRouter uses to
|
|
// de-duplicate concurrent loaders across replicas without holding the per-model
|
|
// advisory lock for the whole load. See ModelLoadJob.
|
|
type LoadJobStore interface {
|
|
ClaimLoadJob(ctx context.Context, trackingKey, owner string) (*ModelLoadJob, bool, error)
|
|
GetLoadJob(ctx context.Context, trackingKey string) (*ModelLoadJob, error)
|
|
UpdateLoadJob(ctx context.Context, ref LoadJobRef, u LoadJobUpdate) error
|
|
FailLoadJob(ctx context.Context, ref LoadJobRef, msg string, workMayRun bool) error
|
|
DeleteLoadJob(ctx context.Context, ref LoadJobRef) error
|
|
DeleteFailedLoadJob(ctx context.Context, ref LoadJobRef) error
|
|
ConfirmLoadOp(ctx context.Context, ref LoadJobRef) error
|
|
}
|
|
|
|
// ReplicaUnloader unloads one replica by the address its row recorded. The
|
|
// eviction and scale-down paths use it: they delete the row first, so the
|
|
// address must travel with the call.
|
|
type ReplicaUnloader interface {
|
|
UnloadReplica(nodeID string, replica NodeModel) error
|
|
}
|
|
|
|
// LoadOperationInstaller starts a backend as a load operation the worker bounds.
|
|
type LoadOperationInstaller interface {
|
|
InstallBackendOp(nodeID, backendType, modelID, galleriesJSON string, replicaIndex int, opID, operationID string, deadline time.Duration, onProgress func(workerctl.BackendInstallProgressEvent), env ...map[string]string) (*workerctl.BackendInstallReply, error)
|
|
}
|
|
|
|
// LoadOperationStopper is the only path that kills remote load work. It stops
|
|
// one operation by id and never "any running backend".
|
|
type LoadOperationStopper interface {
|
|
StopLoadOperation(ctx context.Context, nodeID string, req workerctl.ModelStopRequest) (workerctl.ModelStopReply, error)
|
|
}
|
|
|
|
// LoadOperationRenewer renews and completes load operations on a worker node.
|
|
type LoadOperationRenewer interface {
|
|
OperationControl(nodeID string, req workerctl.OperationRequest) (*workerctl.OperationReply, error)
|
|
}
|
|
|
|
// LoadAttemptStopper is what stopping a failed or cancelled attempt needs: the
|
|
// operation stop for a worker that names operations, and the exact-address stop
|
|
// for a worker that does not.
|
|
type LoadAttemptStopper interface {
|
|
LoadOperationStopper
|
|
ExactModelStopper
|
|
}
|
|
|
|
// LoadOperationControl is the part of a carrier that bounds, renews and stops
|
|
// remote load work. Every carrier of NodeCommandSender must provide it; there
|
|
// is no degraded mode in which a sender silently lacks it. A worker that cannot
|
|
// name operations is a fact about that worker, reported by its install reply
|
|
// (BackendInstallReply.ReportsOperations), and is handled per node.
|
|
//
|
|
// What a carrier owes the callers:
|
|
//
|
|
// - Every method is a request and a reply with its own timeout. A call that
|
|
// gets no reply in time returns an error. It must never block for longer
|
|
// than its documented bound, because the owner loop and the reconciler call
|
|
// these from timers.
|
|
//
|
|
// - OperationControl carries renewals and completions. Callers send a renewal
|
|
// every 5 seconds for each running load. The worker kills an operation that
|
|
// goes 90 seconds without one, so a carrier must deliver a renewal within
|
|
// one cadence (5 s, bounded by the 5 s call timeout) or return an error
|
|
// promptly. A lost renewal costs nothing until the kill TTL. A completion is
|
|
// retried by the caller and its loss is not fatal.
|
|
//
|
|
// - StopLoadOperation is idempotent. It addresses one operation by operation
|
|
// id, process key, process instance and address, and the worker refuses
|
|
// unless the ones given match its own records. A repeat after a stop
|
|
// returns a reply with Terminated set, not an error. A reply with Error set
|
|
// is the worker's refusal: the worker is present and said no.
|
|
//
|
|
// - InstallBackendOp is InstallBackend that names the operation and its
|
|
// longest run as a duration. The deadline is relative so clocks do not
|
|
// matter.
|
|
//
|
|
// - UnloadReplica names the replica's address. With no address nothing is
|
|
// sent: a carrier must never ask a worker to pick a process.
|
|
//
|
|
// - Errors keep the four conditions apart (see .agents/distributed-seams.md).
|
|
// ErrNoRoute is a routing fact only: no route from here right now. A
|
|
// timeout is not ErrNoRoute. An unreachable peer is not a verdict about the
|
|
// worker. A worker's own answer, including a refusal, is a nil error with
|
|
// the refusal in the reply. Only the carrier maps its own sentinel onto
|
|
// ErrNoRoute.
|
|
type LoadOperationControl interface {
|
|
LoadOperationInstaller
|
|
LoadOperationStopper
|
|
LoadOperationRenewer
|
|
ReplicaUnloader
|
|
ExactModelStopper
|
|
}
|
|
|
|
// ConcurrencyConflictResolver returns the names of configured models that
|
|
// share at least one concurrency group with the given model. It is satisfied
|
|
// by *config.ModelConfigLoader and lets the SmartRouter make group-aware
|
|
// placement decisions without importing the config package's full surface.
|
|
type ConcurrencyConflictResolver interface {
|
|
GetModelsConflictingWith(modelName string) []string
|
|
}
|
|
|
|
// PinnedModelResolver reports which configured models are pinned. Satisfied
|
|
// by *config.ModelConfigLoader. The router's eviction paths and the
|
|
// reconciler's idle scale-down exclude these models so `pinned: true` holds
|
|
// cluster-wide, not just against the per-node watchdog (#11101). Deliberate
|
|
// teardown (admin unload, model delete, node drain) intentionally bypasses it.
|
|
type PinnedModelResolver interface {
|
|
GetPinnedModelNames() []string
|
|
}
|
|
|
|
// NodeHealthStore is used by HealthMonitor for node status management.
|
|
type NodeHealthStore interface {
|
|
List(ctx context.Context) ([]BackendNode, error)
|
|
GetNodeModels(ctx context.Context, nodeID string) ([]NodeModel, error)
|
|
MarkOffline(ctx context.Context, nodeID string) error
|
|
MarkUnhealthy(ctx context.Context, nodeID string) error
|
|
MarkHealthy(ctx context.Context, nodeID string) error
|
|
Heartbeat(ctx context.Context, nodeID string, update *HeartbeatUpdate) error
|
|
FindStaleNodes(ctx context.Context, threshold time.Duration) ([]BackendNode, error)
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
}
|
|
|
|
// ModelLocator is used by RemoteUnloaderAdapter for model discovery.
|
|
type ModelLocator interface {
|
|
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
}
|
|
|
|
// ModelLookup is used by DistributedModelStore for model existence queries.
|
|
type ModelLookup interface {
|
|
FindNodeForModel(ctx context.Context, modelName string) (*BackendNode, bool)
|
|
ListAllLoadedModels(ctx context.Context) ([]NodeModel, error)
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
}
|
|
|
|
// InFlightTracker is used by InFlightTrackingClient for request counting.
|
|
type InFlightTracker interface {
|
|
IncrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
DecrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
// RemoveNodeModel drops a stale replica row so the next request reloads the
|
|
// model instead of routing back to a node where it is no longer loaded.
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
}
|
|
|
|
// NodeManager is used by HTTP endpoints for node registration and lifecycle.
|
|
type NodeManager interface {
|
|
Register(ctx context.Context, node *BackendNode, autoApprove bool) error
|
|
Get(ctx context.Context, nodeID string) (*BackendNode, error)
|
|
GetByName(ctx context.Context, name string) (*BackendNode, error)
|
|
List(ctx context.Context) ([]BackendNode, error)
|
|
Deregister(ctx context.Context, nodeID string) error
|
|
ApproveNode(ctx context.Context, nodeID string) error
|
|
MarkOffline(ctx context.Context, nodeID string) error
|
|
MarkDraining(ctx context.Context, nodeID string) error
|
|
MarkHealthy(ctx context.Context, nodeID string) error
|
|
Heartbeat(ctx context.Context, nodeID string, update *HeartbeatUpdate) error
|
|
GetNodeModels(ctx context.Context, nodeID string) ([]NodeModel, error)
|
|
UpdateAuthRefs(ctx context.Context, nodeID, authUserID, apiKeyID string) error
|
|
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error
|
|
RemoveAllNodeModelReplicas(ctx context.Context, nodeID, modelName string) error
|
|
}
|
|
|
|
// BackendClientFactory creates gRPC backend clients. It takes the node id
|
|
// because a dialer that must know WHICH node it is reaching, as a tunnel does,
|
|
// cannot recover it from the address; a direct dialer ignores it.
|
|
type BackendClientFactory interface {
|
|
NewClient(nodeID, address string, parallel bool) grpc.Backend
|
|
}
|
|
|
|
// tokenClientFactory is the default BackendClientFactory that creates gRPC
|
|
// clients with an optional bearer token for distributed auth.
|
|
type tokenClientFactory struct {
|
|
token string
|
|
}
|
|
|
|
func (f *tokenClientFactory) NewClient(_, address string, parallel bool) grpc.Backend {
|
|
if f.token != "" {
|
|
return grpc.NewClientWithToken(address, parallel, nil, false, f.token)
|
|
}
|
|
return grpc.NewClient(address, parallel, nil, false)
|
|
}
|
|
|
|
// WorkerNetDialerFor returns the dial function that reaches one worker's own
|
|
// HTTP server, in the shape http.Transport.DialContext and
|
|
// websocket.Dialer.NetDialContext take. It is keyed by node id, not address,
|
|
// because two workers can report the same HTTP address (NAT, loopback) and a
|
|
// tunnel must still reach the right one.
|
|
type WorkerNetDialerFor func(nodeID string) func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
// DirectWorkerNetDialer dials the address it is handed, whatever the node.
|
|
func DirectWorkerNetDialer() WorkerNetDialerFor {
|
|
// Aggressive keepalive suits the long LAN transfers the file stager makes.
|
|
dial := (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
|
return func(string) func(context.Context, string, string) (net.Conn, error) { return dial }
|
|
}
|