Skip to content

Commit ebef962

Browse files
committed
enhancements, refactorings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent a32c8b3 commit ebef962

33 files changed

Lines changed: 301 additions & 202 deletions

core/cli/worker.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ import (
3333
"github.com/mudler/xlog"
3434
)
3535

36+
// isPathAllowed checks if path is within one of the allowed directories.
37+
func isPathAllowed(path string, allowedDirs []string) bool {
38+
absPath, err := filepath.Abs(path)
39+
if err != nil {
40+
return false
41+
}
42+
resolved, err := filepath.EvalSymlinks(absPath)
43+
if err != nil {
44+
// Path may not exist yet; use the absolute path
45+
resolved = absPath
46+
}
47+
for _, dir := range allowedDirs {
48+
absDir, err := filepath.Abs(dir)
49+
if err != nil {
50+
continue
51+
}
52+
if strings.HasPrefix(resolved, absDir+string(filepath.Separator)) || resolved == absDir {
53+
return true
54+
}
55+
}
56+
return false
57+
}
58+
3659
// WorkerCMD starts a generic worker process for distributed mode.
3760
// Workers are backend-agnostic — they wait for backend.install NATS events
3861
// from the SmartRouter to install and start the required backend.
@@ -118,8 +141,6 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
118141
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
119142
defer shutdownCancel()
120143

121-
go regClient.HeartbeatLoop(shutdownCtx, nodeID, heartbeatInterval, cmd.heartbeatBody)
122-
123144
// Start HTTP file transfer server
124145
httpAddr := cmd.resolveHTTPAddr()
125146
stagingDir := filepath.Join(cmd.ModelsPath, "..", "staging")
@@ -138,6 +159,27 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
138159
}
139160
defer natsClient.Close()
140161

162+
// Start heartbeat goroutine (after NATS is connected so IsConnected check works)
163+
go func() {
164+
ticker := time.NewTicker(heartbeatInterval)
165+
defer ticker.Stop()
166+
for {
167+
select {
168+
case <-shutdownCtx.Done():
169+
return
170+
case <-ticker.C:
171+
if !natsClient.IsConnected() {
172+
xlog.Warn("Skipping heartbeat: NATS disconnected")
173+
continue
174+
}
175+
body := cmd.heartbeatBody()
176+
if err := regClient.Heartbeat(shutdownCtx, nodeID, body); err != nil {
177+
xlog.Warn("Heartbeat failed", "error", err)
178+
}
179+
}
180+
}
181+
}()
182+
141183
// Process supervisor — manages multiple backend gRPC processes on different ports
142184
basePort := 50051
143185
if cmd.Addr != "" {
@@ -242,6 +284,15 @@ func (cmd *WorkerCMD) subscribeFileStaging(natsClient messaging.MessagingClient,
242284
return
243285
}
244286

287+
allowedDirs := []string{cacheDir}
288+
if cmd.ModelsPath != "" {
289+
allowedDirs = append(allowedDirs, cmd.ModelsPath)
290+
}
291+
if !isPathAllowed(req.LocalPath, allowedDirs) {
292+
replyJSON(reply, map[string]string{"error": "path outside allowed directories"})
293+
return
294+
}
295+
245296
if err := fm.Upload(context.Background(), req.Key, req.LocalPath); err != nil {
246297
xlog.Error("File stage failed", "path", req.LocalPath, "key", req.Key, "error", err)
247298
replyJSON(reply, map[string]string{"error": err.Error()})
@@ -450,9 +501,9 @@ func (s *backendSupervisor) stopBackend(backend string) {
450501

451502
// Network I/O outside the lock
452503
client := grpc.NewClientWithToken(bp.addr, false, nil, false, s.cmd.RegistrationToken)
453-
if freeFunc, ok := client.(interface{ Free() error }); ok {
504+
if freeFunc, ok := client.(interface{ Free(context.Context) error }); ok {
454505
xlog.Debug("Calling Free() before stopping backend", "backend", backend)
455-
if err := freeFunc.Free(); err != nil {
506+
if err := freeFunc.Free(context.Background()); err != nil {
456507
xlog.Warn("Free() failed (best-effort)", "backend", backend, "error", err)
457508
}
458509
}
@@ -698,8 +749,8 @@ func (s *backendSupervisor) subscribeLifecycleEvents() {
698749
if targetAddr != "" {
699750
// Best-effort gRPC Free()
700751
client := grpc.NewClientWithToken(targetAddr, false, nil, false, s.cmd.RegistrationToken)
701-
if freeFunc, ok := client.(interface{ Free() error }); ok {
702-
if err := freeFunc.Free(); err != nil {
752+
if freeFunc, ok := client.(interface{ Free(context.Context) error }); ok {
753+
if err := freeFunc.Free(context.Background()); err != nil {
703754
xlog.Warn("Free() failed during model.unload", "error", err, "addr", targetAddr)
704755
}
705756
}

core/http/endpoints/anthropic/messages.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,14 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
434434
_, tokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, func(s string, c *[]schema.Choice) {}, tokenCallback)
435435
if err != nil {
436436
xlog.Error("Anthropic stream model inference failed", "error", err)
437-
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("model inference failed: %v", err))
437+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
438+
Type: "error",
439+
Error: &schema.AnthropicError{
440+
Type: "api_error",
441+
Message: fmt.Sprintf("model inference failed: %v", err),
442+
},
443+
})
444+
return nil
438445
}
439446

440447
// Also check chat deltas for tool calls

core/http/endpoints/openai/chat.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,7 @@ func mergeToolCallDeltas(existing []schema.ToolCall, deltas []schema.ToolCall) [
5959
// @Success 200 {object} schema.OpenAIResponse "Response"
6060
// @Router /v1/chat/completions [post]
6161
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient) echo.HandlerFunc {
62-
var id, textContentToReturn string
63-
var created int
64-
65-
process := func(s string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool) error {
62+
process := func(s string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool, id string, created int) error {
6663
initialMessage := schema.OpenAIResponse{
6764
ID: id,
6865
Created: created,
@@ -119,7 +116,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
119116
close(responses)
120117
return err
121118
}
122-
processTools := func(noAction string, prompt string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool) error {
119+
processTools := func(noAction string, prompt string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool, id string, created int, textContentToReturn *string) error {
123120
// Detect if thinking token is already in prompt or template
124121
var template string
125122
if config.TemplateConfig.UseTokenizerTemplate {
@@ -308,18 +305,18 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
308305
xlog.Debug("[ChatDeltas] Using pre-parsed tool calls from C++ autoparser", "count", len(deltaToolCalls))
309306
functionResults = deltaToolCalls
310307
// Use content/reasoning from deltas too
311-
textContentToReturn = functions.ContentFromChatDeltas(chatDeltas)
308+
*textContentToReturn = functions.ContentFromChatDeltas(chatDeltas)
312309
reasoning = functions.ReasoningFromChatDeltas(chatDeltas)
313310
} else {
314311
// Fallback: parse tool calls from raw text (no chat deltas from backend)
315312
xlog.Debug("[ChatDeltas] no pre-parsed tool calls, falling back to Go-side text parsing")
316313
reasoning = extractor.Reasoning()
317314
cleanedResult := extractor.CleanedContent()
318-
textContentToReturn = functions.ParseTextContent(cleanedResult, config.FunctionsConfig)
315+
*textContentToReturn = functions.ParseTextContent(cleanedResult, config.FunctionsConfig)
319316
cleanedResult = functions.CleanupLLMResult(cleanedResult, config.FunctionsConfig)
320317
functionResults = functions.ParseFunctionCall(cleanedResult, config.FunctionsConfig)
321318
}
322-
xlog.Debug("[ChatDeltas] final tool call decision", "tool_calls", len(functionResults), "text_content", textContentToReturn)
319+
xlog.Debug("[ChatDeltas] final tool call decision", "tool_calls", len(functionResults), "text_content", *textContentToReturn)
323320
noActionToRun := len(functionResults) > 0 && functionResults[0].Name == noAction || len(functionResults) == 0
324321

325322
switch {
@@ -412,7 +409,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
412409
Choices: []schema.Choice{{
413410
Delta: &schema.Message{
414411
Role: "assistant",
415-
Content: &textContentToReturn,
412+
Content: textContentToReturn,
416413
ToolCalls: []schema.ToolCall{
417414
{
418415
Index: i,
@@ -437,9 +434,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
437434
}
438435

439436
return func(c echo.Context) error {
440-
textContentToReturn = ""
441-
id = uuid.New().String()
442-
created = int(time.Now().Unix())
437+
var textContentToReturn string
438+
id := uuid.New().String()
439+
created := int(time.Now().Unix())
443440

444441
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest)
445442
if !ok || input.Model == "" {
@@ -666,9 +663,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
666663

667664
go func() {
668665
if !shouldUseFn {
669-
ended <- process(predInput, input, config, ml, responses, extraUsage)
666+
ended <- process(predInput, input, config, ml, responses, extraUsage, id, created)
670667
} else {
671-
ended <- processTools(noActionName, predInput, input, config, ml, responses, extraUsage)
668+
ended <- processTools(noActionName, predInput, input, config, ml, responses, extraUsage, id, created, &textContentToReturn)
672669
}
673670
}()
674671

@@ -747,6 +744,14 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
747744
}
748745
}
749746

747+
// Drain responses channel to unblock the background goroutine if it's
748+
// still trying to send (e.g., after client disconnect). The goroutine
749+
// calls close(responses) when done, which terminates the drain.
750+
if input.Context.Err() != nil {
751+
go func() { for range responses {} }()
752+
<-ended
753+
}
754+
750755
// MCP streaming tool execution: if we collected MCP tool calls, execute and loop
751756
if hasMCPToolsStream && toolsCalled && len(collectedToolCalls) > 0 {
752757
var hasMCPCalls bool

core/schema/anthropic.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ type AnthropicStreamEvent struct {
121121
Delta *AnthropicStreamDelta `json:"delta,omitempty"`
122122
Message *AnthropicStreamMessage `json:"message,omitempty"`
123123
Usage *AnthropicUsage `json:"usage,omitempty"`
124+
Error *AnthropicError `json:"error,omitempty"`
124125
}
125126

126127
// AnthropicStreamDelta represents the delta in a streaming response

core/services/advisorylock/advisorylock.go

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -36,80 +36,6 @@ func TryWithLockCtx(ctx context.Context, db *gorm.DB, key int64, fn func() error
3636
return true, fn()
3737
}
3838

39-
// Deprecated: Use TryWithLockCtx instead.
40-
//
41-
// TryWithLock attempts a non-blocking advisory lock on a dedicated connection.
42-
// If acquired, fn runs and the lock is released on the same connection.
43-
// Returns (true, fn-error) if acquired, (false, nil) if not.
44-
func TryWithLock(db *gorm.DB, key int64, fn func() error) (bool, error) {
45-
sqlDB, err := db.DB()
46-
if err != nil {
47-
return false, fmt.Errorf("advisorylock: getting sql.DB: %w", err)
48-
}
49-
conn, err := sqlDB.Conn(context.Background())
50-
if err != nil {
51-
return false, fmt.Errorf("advisorylock: getting connection: %w", err)
52-
}
53-
defer conn.Close()
54-
55-
var acquired bool
56-
if err := conn.QueryRowContext(context.Background(),
57-
"SELECT pg_try_advisory_lock($1)", key).Scan(&acquired); err != nil {
58-
return false, fmt.Errorf("advisorylock: try lock %d: %w", key, err)
59-
}
60-
if !acquired {
61-
return false, nil
62-
}
63-
defer conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", key)
64-
return true, fn()
65-
}
66-
67-
// Deprecated: TryLock may use different pool connections for lock/unlock.
68-
// Use TryWithLock instead, which pins a single connection.
69-
//
70-
// TryLock attempts to acquire a PostgreSQL advisory lock (non-blocking).
71-
// Returns true if the lock was acquired.
72-
func TryLock(db *gorm.DB, key int64) bool {
73-
var acquired bool
74-
db.Raw("SELECT pg_try_advisory_lock(?)", key).Scan(&acquired)
75-
return acquired
76-
}
77-
78-
// Deprecated: Unlock may use a different pool connection than TryLock.
79-
// Use TryWithLock instead, which pins a single connection.
80-
//
81-
// Unlock releases a PostgreSQL advisory lock.
82-
func Unlock(db *gorm.DB, key int64) {
83-
db.Exec("SELECT pg_advisory_unlock(?)", key)
84-
}
85-
86-
// Deprecated: Use WithLockCtx instead.
87-
//
88-
// WithLock acquires a PostgreSQL advisory lock for the duration of fn.
89-
// Uses a dedicated database connection to ensure the lock is held correctly.
90-
// The lock is automatically released when fn returns (or panics).
91-
func WithLock(db *gorm.DB, key int64, fn func() error) error {
92-
sqlDB, err := db.DB()
93-
if err != nil {
94-
return fmt.Errorf("advisorylock: getting sql.DB: %w", err)
95-
}
96-
97-
conn, err := sqlDB.Conn(context.Background())
98-
if err != nil {
99-
return fmt.Errorf("advisorylock: getting connection: %w", err)
100-
}
101-
defer conn.Close()
102-
103-
if _, err := conn.ExecContext(context.Background(),
104-
"SELECT pg_advisory_lock($1)", key); err != nil {
105-
return fmt.Errorf("advisorylock: acquiring lock %d: %w", key, err)
106-
}
107-
defer conn.ExecContext(context.Background(),
108-
"SELECT pg_advisory_unlock($1)", key)
109-
110-
return fn()
111-
}
112-
11339
// KeyFromUUID converts a UUID (as [16]byte) to a lock key via FNV-1a hashing.
11440
func KeyFromUUID(b [16]byte) int64 {
11541
h := fnv.New64a()

core/services/advisorylock/advisorylock_test.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ var _ = Describe("AdvisoryLock", func() {
4040
for range 2 {
4141
wg.Go(func() {
4242
defer GinkgoRecover()
43-
err := WithLock(db, lockKey, func() error {
43+
err := WithLockCtx(context.Background(), db, lockKey, func() error {
4444
cur := atomic.AddInt32(&running, 1)
4545
mu.Lock()
4646
if cur > maxRunning {
@@ -66,18 +66,22 @@ var _ = Describe("AdvisoryLock", func() {
6666
Expect(concurrency).To(BeZero(), "detected concurrent execution inside advisory lock")
6767
})
6868

69-
It("acquires and unlocks with TryLock", func() {
69+
It("acquires and releases with TryWithLockCtx", func() {
7070
const lockKey int64 = 888
7171

72-
acquired := TryLock(db, lockKey)
73-
Expect(acquired).To(BeTrue(), "expected TryLock to acquire the lock")
74-
75-
Unlock(db, lockKey)
76-
77-
reacquired := TryLock(db, lockKey)
78-
Expect(reacquired).To(BeTrue(), "expected TryLock to re-acquire the lock after Unlock")
72+
acquired, err := TryWithLockCtx(context.Background(), db, lockKey, func() error {
73+
// Lock is held here
74+
return nil
75+
})
76+
Expect(err).ToNot(HaveOccurred())
77+
Expect(acquired).To(BeTrue(), "expected TryWithLockCtx to acquire the lock")
7978

80-
Unlock(db, lockKey)
79+
// Lock released — should be re-acquirable
80+
reacquired, err := TryWithLockCtx(context.Background(), db, lockKey, func() error {
81+
return nil
82+
})
83+
Expect(err).ToNot(HaveOccurred())
84+
Expect(reacquired).To(BeTrue(), "expected TryWithLockCtx to re-acquire the lock")
8185
})
8286
})
8387

core/services/advisorylock/leader_loop.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package advisorylock
22

33
import (
44
"context"
5+
"runtime/debug"
56
"time"
67

78
"github.com/mudler/xlog"
@@ -21,13 +22,20 @@ func RunLeaderLoop(ctx context.Context, db *gorm.DB, lockKey int64, interval tim
2122
case <-ctx.Done():
2223
return
2324
case <-ticker.C:
24-
_, err := TryWithLockCtx(ctx, db, lockKey, func() error {
25-
fn()
26-
return nil
27-
})
28-
if err != nil {
29-
xlog.Error("Leader loop advisory lock error", "key", lockKey, "error", err)
30-
}
25+
func() {
26+
defer func() {
27+
if r := recover(); r != nil {
28+
xlog.Error("Leader loop callback panicked", "key", lockKey, "panic", r, "stack", string(debug.Stack()))
29+
}
30+
}()
31+
_, err := TryWithLockCtx(ctx, db, lockKey, func() error {
32+
fn()
33+
return nil
34+
})
35+
if err != nil {
36+
xlog.Error("Leader loop advisory lock error", "key", lockKey, "error", err)
37+
}
38+
}()
3139
}
3240
}
3341
}

0 commit comments

Comments
 (0)