Skip to content

Commit 37db1ff

Browse files
committed
refactoring and consolidation
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 29bc8a1 commit 37db1ff

64 files changed

Lines changed: 3561 additions & 756 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/application/application.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (a *Application) waitForHealthyWorker() {
113113
deadline := time.Now().Add(maxWait)
114114

115115
for time.Now().Before(deadline) {
116-
registered, err := a.distributed.Registry.List()
116+
registered, err := a.distributed.Registry.List(context.Background())
117117
if err == nil {
118118
for _, n := range registered {
119119
if n.NodeType == nodes.NodeTypeBackend && n.Status == nodes.StatusHealthy {

core/application/distributed.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package application
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"strings"
@@ -36,6 +37,26 @@ type DistributedServices struct {
3637
Unloader *nodes.RemoteUnloaderAdapter
3738
}
3839

40+
// Shutdown stops all distributed services in reverse initialization order.
41+
// It is safe to call on a nil receiver.
42+
func (ds *DistributedServices) Shutdown() {
43+
if ds == nil {
44+
return
45+
}
46+
if ds.Health != nil {
47+
ds.Health.Stop()
48+
}
49+
if ds.Dispatcher != nil {
50+
ds.Dispatcher.Stop()
51+
}
52+
// AgentBridge has no Close method — its NATS subscriptions are cleaned up
53+
// when the NATS client is closed below.
54+
if ds.Nats != nil {
55+
ds.Nats.Close()
56+
}
57+
xlog.Info("Distributed services shut down")
58+
}
59+
3960
// initDistributed validates distributed mode prerequisites and initializes
4061
// NATS, object storage, node registry, and instance identity.
4162
// Returns nil if distributed mode is not enabled.
@@ -46,6 +67,11 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
4667

4768
xlog.Info("Distributed mode enabled — validating prerequisites")
4869

70+
// Validate distributed config (NATS URL, S3 credential pairing, durations, etc.)
71+
if err := cfg.Distributed.Validate(); err != nil {
72+
return nil, err
73+
}
74+
4975
// Validate PostgreSQL is configured (auth DB must be PostgreSQL for distributed mode)
5076
if !cfg.Auth.Enabled {
5177
return nil, fmt.Errorf("distributed mode requires authentication to be enabled (--auth / LOCALAI_AUTH=true)")
@@ -54,11 +80,6 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
5480
return nil, fmt.Errorf("distributed mode requires PostgreSQL for auth database (got %q)", sanitize.URL(cfg.Auth.DatabaseURL))
5581
}
5682

57-
// Validate NATS
58-
if cfg.Distributed.NatsURL == "" {
59-
return nil, fmt.Errorf("distributed mode requires --nats-url / LOCALAI_NATS_URL")
60-
}
61-
6283
// Generate instance ID if not set
6384
if cfg.Distributed.InstanceID == "" {
6485
cfg.Distributed.InstanceID = uuid.New().String()
@@ -134,6 +155,8 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
134155
healthMon := nodes.NewHealthMonitor(registry, authDB,
135156
cfg.Distributed.HealthCheckIntervalOrDefault(),
136157
cfg.Distributed.StaleNodeThresholdOrDefault(),
158+
routerAuthToken,
159+
cfg.Distributed.PerModelHealthCheck,
137160
)
138161

139162
// Initialize job store
@@ -185,7 +208,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
185208
xlog.Info("File stager initialized (S3+NATS)")
186209
} else {
187210
fileStager = nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
188-
node, err := registry.Get(nodeID)
211+
node, err := registry.Get(context.Background(), nodeID)
189212
if err != nil {
190213
return "", err
191214
}

core/application/startup.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ func New(opts ...config.AppOption) (*Application, error) {
268268
go func() {
269269
<-options.Context.Done()
270270
xlog.Debug("Context canceled, shutting down")
271+
application.distributed.Shutdown()
271272
err := application.ModelLoader().StopAllGRPC()
272273
if err != nil {
273274
xlog.Error("error while stopping all grpc backends", "error", err)

core/cli/agent_worker.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ type AgentWorkerCMD struct {
5151
// NATS subjects
5252
Subject string `env:"LOCALAI_AGENT_SUBJECT" default:"agent.execute" help:"NATS subject for agent execution" group:"distributed"`
5353
Queue string `env:"LOCALAI_AGENT_QUEUE" default:"agent-workers" help:"NATS queue group name" group:"distributed"`
54+
55+
// Timeouts
56+
MCPCIJobTimeout string `env:"LOCALAI_MCP_CI_JOB_TIMEOUT" default:"10m" help:"Timeout for MCP CI job execution" group:"distributed"`
5457
}
5558

5659
func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
@@ -90,7 +93,10 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
9093
}
9194

9295
// Start heartbeat
93-
heartbeatInterval, _ := time.ParseDuration(cmd.HeartbeatInterval)
96+
heartbeatInterval, err := time.ParseDuration(cmd.HeartbeatInterval)
97+
if err != nil && cmd.HeartbeatInterval != "" {
98+
xlog.Warn("invalid heartbeat interval, using default 10s", "input", cmd.HeartbeatInterval, "error", err)
99+
}
94100
heartbeatInterval = cmp.Or(heartbeatInterval, 10*time.Second)
95101
// Context cancelled on shutdown — used by heartbeat and other background goroutines
96102
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
@@ -145,8 +151,14 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
145151
// Subscribe to MCP CI job execution (load-balanced across agent workers).
146152
// In distributed mode, MCP CI jobs are routed here because the frontend
147153
// cannot create MCP sessions (e.g., stdio servers using docker).
154+
mcpCIJobTimeout, err := time.ParseDuration(cmd.MCPCIJobTimeout)
155+
if err != nil && cmd.MCPCIJobTimeout != "" {
156+
xlog.Warn("invalid MCP CI job timeout, using default 10m", "input", cmd.MCPCIJobTimeout, "error", err)
157+
}
158+
mcpCIJobTimeout = cmp.Or(mcpCIJobTimeout, config.DefaultMCPCIJobTimeout)
159+
148160
natsClient.QueueSubscribe(messaging.SubjectMCPCIJobsNew, messaging.QueueWorkers, func(data []byte) {
149-
handleMCPCIJob(shutdownCtx, data, apiURL, cmd.APIToken, natsClient)
161+
handleMCPCIJob(shutdownCtx, data, apiURL, cmd.APIToken, natsClient, mcpCIJobTimeout)
150162
})
151163

152164
// Subscribe to backend stop events to clean up cached MCP sessions.
@@ -277,7 +289,7 @@ func sendMCPDiscoveryReply(reply func([]byte), servers []mcpRemote.MCPServerInfo
277289

278290
// handleMCPCIJob processes an MCP CI job on the agent worker.
279291
// The agent worker can create MCP sessions (has docker) and call the LocalAI API for inference.
280-
func handleMCPCIJob(shutdownCtx context.Context, data []byte, apiURL, apiToken string, natsClient *messaging.Client) {
292+
func handleMCPCIJob(shutdownCtx context.Context, data []byte, apiURL, apiToken string, natsClient messaging.MessagingClient, jobTimeout time.Duration) {
281293
var evt jobs.JobEvent
282294
if err := json.Unmarshal(data, &evt); err != nil {
283295
xlog.Error("Failed to unmarshal job event", "error", err)
@@ -353,7 +365,7 @@ func handleMCPCIJob(shutdownCtx context.Context, data []byte, apiURL, apiToken s
353365
llm := clients.NewLocalAILLM(task.Model, apiToken, apiURL)
354366

355367
// Build cogito options
356-
ctx, cancel := context.WithTimeout(shutdownCtx, 10*time.Minute)
368+
ctx, cancel := context.WithTimeout(shutdownCtx, jobTimeout)
357369
defer cancel()
358370

359371
// Update job status to running in DB
@@ -433,11 +445,11 @@ func handleMCPCIJob(shutdownCtx context.Context, data []byte, apiURL, apiToken s
433445
xlog.Info("MCP CI job completed", "jobID", evt.JobID, "resultLen", len(result))
434446
}
435447

436-
func publishJobStatus(nc *messaging.Client, jobID, status, message string) {
448+
func publishJobStatus(nc messaging.MessagingClient, jobID, status, message string) {
437449
jobs.PublishJobProgress(nc, jobID, status, message)
438450
}
439451

440-
func publishJobResult(nc *messaging.Client, jobID, status, result, errMsg string) {
452+
func publishJobResult(nc messaging.MessagingClient, jobID, status, result, errMsg string) {
441453
jobs.PublishJobResult(nc, jobID, status, result, errMsg)
442454
}
443455

core/cli/worker.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
109109
}
110110

111111
xlog.Info("Registered with frontend", "nodeID", nodeID, "frontend", cmd.RegisterTo)
112-
heartbeatInterval, _ := time.ParseDuration(cmd.HeartbeatInterval)
112+
heartbeatInterval, err := time.ParseDuration(cmd.HeartbeatInterval)
113+
if err != nil && cmd.HeartbeatInterval != "" {
114+
xlog.Warn("invalid heartbeat interval, using default 10s", "input", cmd.HeartbeatInterval, "error", err)
115+
}
113116
heartbeatInterval = cmp.Or(heartbeatInterval, 10*time.Second)
114117
// Context cancelled on shutdown — used by heartbeat and other background goroutines
115118
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
@@ -184,7 +187,7 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
184187
}
185188

186189
// subscribeFileStaging subscribes to NATS file staging subjects for this node.
187-
func (cmd *WorkerCMD) subscribeFileStaging(natsClient *messaging.Client, nodeID string) error {
190+
func (cmd *WorkerCMD) subscribeFileStaging(natsClient messaging.MessagingClient, nodeID string) error {
188191
// Create FileManager with same S3 config as the frontend
189192
s3Store, err := storage.NewS3Store(storage.S3Config{
190193
Endpoint: cmd.StorageURL,
@@ -346,7 +349,7 @@ type backendSupervisor struct {
346349
systemState *system.SystemState
347350
galleries []config.Gallery
348351
nodeID string
349-
nats *messaging.Client
352+
nats messaging.MessagingClient
350353
sigCh chan<- os.Signal // send shutdown signal instead of os.Exit
351354

352355
mu sync.Mutex

core/cli/workerregistry/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (c *RegistrationClient) Register(ctx context.Context, body map[string]any)
6868
}
6969
defer resp.Body.Close()
7070

71-
if resp.StatusCode != http.StatusOK {
71+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
7272
return "", "", fmt.Errorf("registration failed with status %d", resp.StatusCode)
7373
}
7474

core/config/distributed_config.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package config
22

33
import (
44
"cmp"
5+
"fmt"
56
"time"
7+
8+
"github.com/mudler/xlog"
69
)
710

811
// DistributedConfig holds configuration for horizontal scaling mode.
@@ -28,13 +31,50 @@ type DistributedConfig struct {
2831
DrainTimeout time.Duration // Time to wait for in-flight requests during drain (default 30s)
2932
HealthCheckInterval time.Duration // Health monitor check interval (default 15s)
3033
StaleNodeThreshold time.Duration // Time before a node is considered stale (default 60s)
34+
PerModelHealthCheck bool // Enable per-model backend health checking (default false)
35+
MCPCIJobTimeout time.Duration // MCP CI job execution timeout (default 10m)
3136

3237
MaxUploadSize int64 // Maximum upload body size in bytes (default 50 GB)
3338

3439
AgentWorkerConcurrency int `yaml:"agent_worker_concurrency" json:"agent_worker_concurrency" env:"LOCALAI_AGENT_WORKER_CONCURRENCY"`
3540
JobWorkerConcurrency int `yaml:"job_worker_concurrency" json:"job_worker_concurrency" env:"LOCALAI_JOB_WORKER_CONCURRENCY"`
3641
}
3742

43+
// Validate checks that the distributed configuration is internally consistent.
44+
// It returns nil if distributed mode is disabled.
45+
func (c DistributedConfig) Validate() error {
46+
if !c.Enabled {
47+
return nil
48+
}
49+
if c.NatsURL == "" {
50+
return fmt.Errorf("distributed mode requires --nats-url / LOCALAI_NATS_URL")
51+
}
52+
// S3 credentials must be paired
53+
if (c.StorageAccessKey != "" && c.StorageSecretKey == "") ||
54+
(c.StorageAccessKey == "" && c.StorageSecretKey != "") {
55+
return fmt.Errorf("storage-access-key and storage-secret-key must both be set or both empty")
56+
}
57+
// Warn about missing registration token (not an error)
58+
if c.RegistrationToken == "" {
59+
xlog.Warn("distributed mode running without registration token — node endpoints are unprotected")
60+
}
61+
// Check for negative durations
62+
for name, d := range map[string]time.Duration{
63+
"mcp-tool-timeout": c.MCPToolTimeout,
64+
"mcp-discovery-timeout": c.MCPDiscoveryTimeout,
65+
"worker-wait-timeout": c.WorkerWaitTimeout,
66+
"drain-timeout": c.DrainTimeout,
67+
"health-check-interval": c.HealthCheckInterval,
68+
"stale-node-threshold": c.StaleNodeThreshold,
69+
"mcp-ci-job-timeout": c.MCPCIJobTimeout,
70+
} {
71+
if d < 0 {
72+
return fmt.Errorf("%s must not be negative", name)
73+
}
74+
}
75+
return nil
76+
}
77+
3878
// Distributed config options
3979

4080
var EnableDistributed = func(o *ApplicationConfig) {
@@ -101,6 +141,7 @@ const (
101141
DefaultDrainTimeout = 30 * time.Second
102142
DefaultHealthCheckInterval = 15 * time.Second
103143
DefaultStaleNodeThreshold = 60 * time.Second
144+
DefaultMCPCIJobTimeout = 10 * time.Minute
104145
)
105146

106147
// DefaultMaxUploadSize is the default maximum upload body size (50 GB).
@@ -136,6 +177,11 @@ func (c DistributedConfig) StaleNodeThresholdOrDefault() time.Duration {
136177
return cmp.Or(c.StaleNodeThreshold, DefaultStaleNodeThreshold)
137178
}
138179

180+
// MCPCIJobTimeoutOrDefault returns the configured MCP CI job timeout or the default.
181+
func (c DistributedConfig) MCPCIJobTimeoutOrDefault() time.Duration {
182+
return cmp.Or(c.MCPCIJobTimeout, DefaultMCPCIJobTimeout)
183+
}
184+
139185
// MaxUploadSizeOrDefault returns the configured max upload size or the default.
140186
func (c DistributedConfig) MaxUploadSizeOrDefault() int64 {
141187
if c.MaxUploadSize > 0 {

core/http/endpoints/anthropic/messages.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,13 @@ func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evalu
6161
if mcpErr == nil {
6262
mcpExecutor = mcpTools.NewToolExecutor(c.Request().Context(), natsClient, cfg.Name, remote, stdio, mcpServers)
6363

64-
// Prompt and resource injection (local mode only)
65-
if natsClient == nil {
66-
namedSessions, sessErr := mcpTools.NamedSessionsFromMCPConfig(cfg.Name, remote, stdio, mcpServers)
67-
if sessErr == nil && len(namedSessions) > 0 {
68-
mcpCtx, _ := mcpTools.InjectMCPContext(c.Request().Context(), namedSessions, mcpPromptName, mcpPromptArgs, mcpResourceURIs)
69-
if mcpCtx != nil {
70-
openAIMessages = append(mcpCtx.PromptMessages, openAIMessages...)
71-
mcpTools.AppendResourceSuffix(openAIMessages, mcpCtx.ResourceSuffix)
72-
}
64+
// Prompt and resource injection (pre-processing step — resolves locally regardless of distributed mode)
65+
namedSessions, sessErr := mcpTools.NamedSessionsFromMCPConfig(cfg.Name, remote, stdio, mcpServers)
66+
if sessErr == nil && len(namedSessions) > 0 {
67+
mcpCtx, _ := mcpTools.InjectMCPContext(c.Request().Context(), namedSessions, mcpPromptName, mcpPromptArgs, mcpResourceURIs)
68+
if mcpCtx != nil {
69+
openAIMessages = append(mcpCtx.PromptMessages, openAIMessages...)
70+
mcpTools.AppendResourceSuffix(openAIMessages, mcpCtx.ResourceSuffix)
7371
}
7472
}
7573

0 commit comments

Comments
 (0)