Skip to content

Commit 4f5808b

Browse files
committed
fix(mcp): wire in-process scheduling
Use an explicit MCP scheduling DTO and route in-process scheduling calls through the distributed node registry so the embedded assistant matches the REST scheduling surface. Assisted-by: Hephaestus:openai/gpt-5.5
1 parent b23ce45 commit 4f5808b

5 files changed

Lines changed: 241 additions & 13 deletions

File tree

core/application/application.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,12 +553,17 @@ func (a *Application) start() error {
553553
// once at startup and reused across chat sessions that opt in via metadata.
554554
if !a.applicationConfig.DisableLocalAIAssistant {
555555
holder := mcpTools.NewLocalAIAssistantHolder()
556+
var nodeRegistry *nodes.NodeRegistry
557+
if a.distributed != nil {
558+
nodeRegistry = a.distributed.Registry
559+
}
556560
assistantClient := localaiInproc.New(
557561
a.applicationConfig,
558562
a.applicationConfig.SystemState,
559563
a.backendLoader,
560564
a.modelLoader,
561565
a.galleryService,
566+
nodeRegistry,
562567
)
563568
// Wire usage tracking so the assistant's get_usage_stats tool
564569
// returns real data; nil values keep the tool returning a clear

pkg/mcp/localaitools/dto.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package localaitools
22

33
import (
4-
"github.com/mudler/LocalAI/core/services/nodes"
4+
"time"
5+
56
"github.com/mudler/LocalAI/core/services/voiceprofile"
67
)
78

@@ -119,9 +120,25 @@ type SetNodeVRAMBudgetRequest struct {
119120
}
120121

121122
// ModelSchedulingConfig is the REST/MCP wire shape for one per-model
122-
// distributed scheduling rule. The NodeSelector field is a JSON object encoded
123-
// as a string, matching /api/nodes/scheduling responses.
124-
type ModelSchedulingConfig = nodes.ModelSchedulingConfig
123+
// distributed scheduling rule. Keep this DTO explicit instead of aliasing the
124+
// node-registry model so MCP contracts remain owned by localaitools while still
125+
// matching /api/nodes/scheduling JSON.
126+
type ModelSchedulingConfig struct {
127+
ID string `json:"id"`
128+
ModelName string `json:"model_name"`
129+
NodeSelector string `json:"node_selector,omitempty"`
130+
MinReplicas int `json:"min_replicas"`
131+
MaxReplicas int `json:"max_replicas"`
132+
SpreadAll bool `json:"spread_all,omitempty"`
133+
RoutePolicy string `json:"route_policy,omitempty"`
134+
BalanceAbsThreshold int `json:"balance_abs_threshold,omitempty"`
135+
BalanceRelThreshold float64 `json:"balance_rel_threshold,omitempty"`
136+
MinPrefixMatch float64 `json:"min_prefix_match,omitempty"`
137+
UnsatisfiableUntil *time.Time `json:"unsatisfiable_until,omitempty"`
138+
UnsatisfiableTicks int `json:"unsatisfiable_ticks"`
139+
CreatedAt time.Time `json:"created_at"`
140+
UpdatedAt time.Time `json:"updated_at"`
141+
}
125142

126143
// SetSchedulingRequest is the input for set_scheduling. It mirrors
127144
// /api/nodes/scheduling so standalone MCP and REST callers preserve the same

pkg/mcp/localaitools/inproc/client.go

Lines changed: 116 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
"github.com/mudler/LocalAI/core/schema"
2424
"github.com/mudler/LocalAI/core/services/galleryop"
2525
"github.com/mudler/LocalAI/core/services/modeladmin"
26+
"github.com/mudler/LocalAI/core/services/nodes"
27+
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
2628
"github.com/mudler/LocalAI/core/services/routing/billing"
2729
"github.com/mudler/LocalAI/core/services/routing/pii"
2830
"github.com/mudler/LocalAI/core/services/routing/router"
@@ -47,6 +49,7 @@ type Client struct {
4749
ConfigLoader *config.ModelConfigLoader
4850
ModelLoader *model.ModelLoader
4951
Gallery *galleryop.GalleryService
52+
NodeRegistry *nodes.NodeRegistry
5053
VoiceProfiles *voiceprofile.Store
5154

5255
// StatsRecorder and FallbackUser are optional — they back the
@@ -75,13 +78,18 @@ type Client struct {
7578
// except ModelLoader (used only for SystemInfo's loaded-models report and
7679
// best-effort ShutdownModel calls during config edits) and the stats
7780
// fields (StatsRecorder, FallbackUser) which gate get_usage_stats.
78-
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService) *Client {
81+
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService, registries ...*nodes.NodeRegistry) *Client {
82+
var registry *nodes.NodeRegistry
83+
if len(registries) > 0 {
84+
registry = registries[0]
85+
}
7986
return &Client{
8087
AppConfig: appConfig,
8188
SystemState: systemState,
8289
ConfigLoader: cl,
8390
ModelLoader: ml,
8491
Gallery: gs,
92+
NodeRegistry: registry,
8593
VoiceProfiles: voiceprofile.NewStore(appConfig.DataPath),
8694
modelAdmin: modeladmin.NewConfigService(cl, appConfig),
8795
}
@@ -501,20 +509,119 @@ func (c *Client) ListNodes(_ context.Context) ([]localaitools.Node, error) {
501509
return []localaitools.Node{}, nil
502510
}
503511

504-
func (c *Client) ListScheduling(_ context.Context) ([]localaitools.ModelSchedulingConfig, error) {
505-
return []localaitools.ModelSchedulingConfig{}, nil
512+
func (c *Client) ListScheduling(ctx context.Context) ([]localaitools.ModelSchedulingConfig, error) {
513+
if c.NodeRegistry == nil {
514+
return []localaitools.ModelSchedulingConfig{}, nil
515+
}
516+
configs, err := c.NodeRegistry.ListModelSchedulings(ctx)
517+
if err != nil {
518+
return nil, err
519+
}
520+
return localaitools.SchedulingConfigsFromNodes(configs), nil
506521
}
507522

508-
func (c *Client) GetScheduling(_ context.Context, _ string) (*localaitools.ModelSchedulingConfig, error) {
509-
return nil, errors.New("model scheduling is only available in distributed mode")
523+
func (c *Client) GetScheduling(ctx context.Context, modelName string) (*localaitools.ModelSchedulingConfig, error) {
524+
if modelName == "" {
525+
return nil, errors.New("model_name is required")
526+
}
527+
if c.NodeRegistry == nil {
528+
return nil, errors.New("model scheduling is only available in distributed mode")
529+
}
530+
config, err := c.NodeRegistry.GetModelScheduling(ctx, modelName)
531+
if err != nil {
532+
return nil, err
533+
}
534+
if config == nil {
535+
return nil, nil
536+
}
537+
out := localaitools.SchedulingConfigFromNode(*config)
538+
return &out, nil
510539
}
511540

512-
func (c *Client) SetScheduling(_ context.Context, _ localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
513-
return nil, errors.New("model scheduling is only available in distributed mode")
541+
func (c *Client) SetScheduling(ctx context.Context, req localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
542+
if req.ModelName == "" {
543+
return nil, errors.New("model_name is required")
544+
}
545+
if c.NodeRegistry == nil {
546+
return nil, errors.New("model scheduling is only available in distributed mode")
547+
}
548+
549+
existing, err := c.NodeRegistry.GetModelScheduling(ctx, req.ModelName)
550+
if err != nil {
551+
return nil, fmt.Errorf("load existing scheduling config: %w", err)
552+
}
553+
routePolicy := ""
554+
absThr := 0
555+
relThr := 0.0
556+
minMatch := 0.0
557+
if existing != nil {
558+
routePolicy = existing.RoutePolicy
559+
absThr = existing.BalanceAbsThreshold
560+
relThr = existing.BalanceRelThreshold
561+
minMatch = existing.MinPrefixMatch
562+
}
563+
if req.RoutePolicy != nil {
564+
routePolicy = *req.RoutePolicy
565+
}
566+
if req.BalanceAbsThreshold != nil {
567+
absThr = *req.BalanceAbsThreshold
568+
}
569+
if req.BalanceRelThreshold != nil {
570+
relThr = *req.BalanceRelThreshold
571+
}
572+
if req.MinPrefixMatch != nil {
573+
minMatch = *req.MinPrefixMatch
574+
}
575+
if req.SpreadAll && (req.MinReplicas != 0 || req.MaxReplicas != 0) {
576+
return nil, errors.New("spread_all and min_replicas/max_replicas are mutually exclusive")
577+
}
578+
if req.MinReplicas < 0 {
579+
return nil, errors.New("min_replicas must be >= 0")
580+
}
581+
if req.MaxReplicas < 0 {
582+
return nil, errors.New("max_replicas must be >= 0")
583+
}
584+
if req.MaxReplicas > 0 && req.MinReplicas > req.MaxReplicas {
585+
return nil, errors.New("min_replicas must be <= max_replicas")
586+
}
587+
if err := prefixcache.ValidateThresholds(routePolicy, absThr, relThr, minMatch); err != nil {
588+
return nil, err
589+
}
590+
591+
var selectorJSON string
592+
if len(req.NodeSelector) > 0 {
593+
b, err := json.Marshal(req.NodeSelector)
594+
if err != nil {
595+
return nil, fmt.Errorf("invalid node_selector: %w", err)
596+
}
597+
selectorJSON = string(b)
598+
}
599+
config := &nodes.ModelSchedulingConfig{
600+
ModelName: req.ModelName,
601+
NodeSelector: selectorJSON,
602+
MinReplicas: req.MinReplicas,
603+
MaxReplicas: req.MaxReplicas,
604+
SpreadAll: req.SpreadAll,
605+
RoutePolicy: routePolicy,
606+
BalanceAbsThreshold: absThr,
607+
BalanceRelThreshold: relThr,
608+
MinPrefixMatch: minMatch,
609+
}
610+
if err := c.NodeRegistry.SetModelScheduling(ctx, config); err != nil {
611+
return nil, err
612+
}
613+
out := localaitools.SchedulingConfigFromNode(*config)
614+
return &out, nil
514615
}
515616

516-
func (c *Client) DeleteScheduling(_ context.Context, _ string) error {
517-
return errors.New("model scheduling is only available in distributed mode")
617+
func (c *Client) DeleteScheduling(ctx context.Context, modelName string) error {
618+
if modelName == "" {
619+
return errors.New("model_name is required")
620+
}
621+
if c.NodeRegistry == nil {
622+
return errors.New("model scheduling is only available in distributed mode")
623+
}
624+
return c.NodeRegistry.DeleteModelScheduling(ctx, modelName)
518625
}
519626

520627
func (c *Client) SetNodeVRAMBudget(_ context.Context, _, _ string) error {

pkg/mcp/localaitools/inproc/client_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import (
1313
"github.com/mudler/LocalAI/core/config"
1414
"github.com/mudler/LocalAI/core/gallery"
1515
"github.com/mudler/LocalAI/core/services/galleryop"
16+
"github.com/mudler/LocalAI/core/services/nodes"
1617
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
1718
"github.com/mudler/LocalAI/pkg/system"
19+
"gorm.io/driver/sqlite"
20+
"gorm.io/gorm"
1821
)
1922

2023
// Regression spec for the bug we fixed when channel sends were
@@ -124,3 +127,69 @@ var _ = Describe("inproc.Client model aliases", func() {
124127
})
125128
})
126129
})
130+
131+
var _ = Describe("inproc.Client model scheduling", func() {
132+
var (
133+
ctx context.Context
134+
registry *nodes.NodeRegistry
135+
c *Client
136+
stringp = func(s string) *string { return &s }
137+
floatp = func(f float64) *float64 { return &f }
138+
)
139+
140+
BeforeEach(func() {
141+
ctx = context.Background()
142+
db, err := gorm.Open(sqlite.Open(filepath.Join(GinkgoT().TempDir(), "nodes.db")), &gorm.Config{})
143+
Expect(err).ToNot(HaveOccurred())
144+
registry, err = nodes.NewNodeRegistry(db)
145+
Expect(err).ToNot(HaveOccurred())
146+
147+
tempDir := GinkgoT().TempDir()
148+
systemState, err := system.GetSystemState(system.WithModelPath(tempDir))
149+
Expect(err).ToNot(HaveOccurred())
150+
appConfig := config.NewApplicationConfig(config.WithSystemState(systemState))
151+
c = New(appConfig, systemState, config.NewModelConfigLoader(tempDir), nil, nil, registry)
152+
})
153+
154+
It("sets, lists, gets, merges, and deletes scheduling configs through the registry", func() {
155+
created, err := c.SetScheduling(ctx, localaitools.SetSchedulingRequest{
156+
ModelName: "qwen",
157+
NodeSelector: map[string]string{"gpu": "nvidia"},
158+
MinReplicas: 1,
159+
MaxReplicas: 2,
160+
RoutePolicy: stringp("prefix_cache"),
161+
MinPrefixMatch: floatp(0.4),
162+
})
163+
Expect(err).ToNot(HaveOccurred())
164+
Expect(created.ModelName).To(Equal("qwen"))
165+
Expect(created.NodeSelector).To(Equal(`{"gpu":"nvidia"}`))
166+
Expect(created.RoutePolicy).To(Equal("prefix_cache"))
167+
Expect(created.MinPrefixMatch).To(Equal(0.4))
168+
169+
listed, err := c.ListScheduling(ctx)
170+
Expect(err).ToNot(HaveOccurred())
171+
Expect(listed).To(HaveLen(1))
172+
Expect(listed[0].ModelName).To(Equal("qwen"))
173+
174+
updated, err := c.SetScheduling(ctx, localaitools.SetSchedulingRequest{
175+
ModelName: "qwen",
176+
MinReplicas: 2,
177+
MaxReplicas: 3,
178+
})
179+
Expect(err).ToNot(HaveOccurred())
180+
Expect(updated.MinReplicas).To(Equal(2))
181+
Expect(updated.MaxReplicas).To(Equal(3))
182+
Expect(updated.RoutePolicy).To(Equal("prefix_cache"))
183+
Expect(updated.MinPrefixMatch).To(Equal(0.4))
184+
185+
got, err := c.GetScheduling(ctx, "qwen")
186+
Expect(err).ToNot(HaveOccurred())
187+
Expect(got).ToNot(BeNil())
188+
Expect(got.MaxReplicas).To(Equal(3))
189+
190+
Expect(c.DeleteScheduling(ctx, "qwen")).To(Succeed())
191+
missing, err := c.GetScheduling(ctx, "qwen")
192+
Expect(err).ToNot(HaveOccurred())
193+
Expect(missing).To(BeNil())
194+
})
195+
})

pkg/mcp/localaitools/scheduling.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package localaitools
2+
3+
import "github.com/mudler/LocalAI/core/services/nodes"
4+
5+
func SchedulingConfigFromNode(config nodes.ModelSchedulingConfig) ModelSchedulingConfig {
6+
return ModelSchedulingConfig{
7+
ID: config.ID,
8+
ModelName: config.ModelName,
9+
NodeSelector: config.NodeSelector,
10+
MinReplicas: config.MinReplicas,
11+
MaxReplicas: config.MaxReplicas,
12+
SpreadAll: config.SpreadAll,
13+
RoutePolicy: config.RoutePolicy,
14+
BalanceAbsThreshold: config.BalanceAbsThreshold,
15+
BalanceRelThreshold: config.BalanceRelThreshold,
16+
MinPrefixMatch: config.MinPrefixMatch,
17+
UnsatisfiableUntil: config.UnsatisfiableUntil,
18+
UnsatisfiableTicks: config.UnsatisfiableTicks,
19+
CreatedAt: config.CreatedAt,
20+
UpdatedAt: config.UpdatedAt,
21+
}
22+
}
23+
24+
func SchedulingConfigsFromNodes(configs []nodes.ModelSchedulingConfig) []ModelSchedulingConfig {
25+
out := make([]ModelSchedulingConfig, 0, len(configs))
26+
for _, config := range configs {
27+
out = append(out, SchedulingConfigFromNode(config))
28+
}
29+
return out
30+
}

0 commit comments

Comments
 (0)