Skip to content

Commit 6dcb1d5

Browse files
localai-botmudler
authored andcommitted
feat(vram): per-node VRAM allocation budget (LOCALAI_VRAM_BUDGET) (#10833)
* feat(vram): add vrambudget primitive for per-node VRAM caps Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): apply default VRAM budget in xsysinfo aggregate getters Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): wire LOCALAI_VRAM_BUDGET flag to xsysinfo default budget Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): persist VRAM budget via runtime settings with live apply Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(vram): reset process-global VRAM budget after runtime-settings spec Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add VRAM budget field to Settings page Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): store and enforce per-node VRAM budget in the node registry Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): apply per-node VRAM budget in router hardware defaults Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): report worker VRAM budget in node registration The distributed worker now reports its operator-set VRAM budget string (LOCALAI_VRAM_BUDGET) to the server on registration. The worker keeps reporting RAW total/available VRAM and never sets the xsysinfo process-global budget (that stays standalone-only); the server resolves and enforces the budget uniformly (Task 6). Also closes a Task 6 gap: on re-registration, a struct Updates zero-skips an empty budget, so a worker that dropped LOCALAI_VRAM_BUDGET left the stale cap in place. For non-admin-override nodes the budget columns are now force-written (map Updates) even when empty, so removing the env var clears the cap; admin overrides are preserved unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(vram): drop em dash from worker-clear comment Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add node VRAM budget admin endpoints Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add node VRAM budget control to the node UI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): expose set_node_vram_budget MCP admin tool Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(vram): document LOCALAI_VRAM_BUDGET and node VRAM budget UI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vram): avoid double-applying VRAM budget in GetResourceAggregateInfo The GPU-branch aggregate returned by GetResourceInfo is sourced from GetGPUAggregateInfo, which already caps total/free/used against the process-wide VRAM budget. GetResourceAggregateInfo then applied the budget a second time. For an absolute budget this is idempotent, but for a percentage budget b.Apply resolves the ceiling as a fraction of its input total, so a second pass yields P*(P*T) instead of P*T and distorts UsagePercent (read by the memory reclaimer in pkg/model/watchdog.go). Remove the redundant second application so the budget is applied exactly once, against the raw physical totals, upstream in GetGPUAggregateInfo. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vram): implement SetNodeVRAMBudget on mcp assistant test stub The LocalAIClient interface gained SetNodeVRAMBudget; the stubClient in core/http/endpoints/mcp used by the assistant tests is a separate implementer and needs the method too (broke golangci-lint typecheck and both test jobs). 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> Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 3fccaa7 commit 6dcb1d5

38 files changed

Lines changed: 1365 additions & 24 deletions

core/cli/run.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"github.com/mudler/LocalAI/pkg/modelartifacts"
2020
"github.com/mudler/LocalAI/pkg/signals"
2121
"github.com/mudler/LocalAI/pkg/system"
22+
"github.com/mudler/LocalAI/pkg/vrambudget"
23+
"github.com/mudler/LocalAI/pkg/xsysinfo"
2224
"github.com/mudler/xlog"
2325
)
2426

@@ -96,6 +98,7 @@ type RunCMD struct {
9698
WatchdogInterval string `env:"LOCALAI_WATCHDOG_INTERVAL,WATCHDOG_INTERVAL" default:"500ms" help:"Interval between watchdog checks (e.g., 500ms, 5s, 1m) (default: 500ms)" group:"backends"`
9799
EnableMemoryReclaimer bool `env:"LOCALAI_MEMORY_RECLAIMER,MEMORY_RECLAIMER,LOCALAI_GPU_RECLAIMER,GPU_RECLAIMER" default:"false" help:"Enable memory threshold monitoring to auto-evict backends when memory usage exceeds threshold (uses GPU VRAM if available, otherwise RAM)" group:"backends"`
98100
MemoryReclaimerThreshold float64 `env:"LOCALAI_MEMORY_RECLAIMER_THRESHOLD,MEMORY_RECLAIMER_THRESHOLD,LOCALAI_GPU_RECLAIMER_THRESHOLD,GPU_RECLAIMER_THRESHOLD" default:"0.95" help:"Memory usage threshold (0.0-1.0) that triggers backend eviction (default 0.95 = 95%%)" group:"backends"`
101+
VRAMBudget string `env:"LOCALAI_VRAM_BUDGET" help:"Cap VRAM used for model allocation on this node, as a percentage (e.g. 80%) or absolute amount (e.g. 12GB). Empty uses all detected VRAM." group:"backends"`
99102
ForceEvictionWhenBusy bool `env:"LOCALAI_FORCE_EVICTION_WHEN_BUSY,FORCE_EVICTION_WHEN_BUSY" default:"false" help:"Force eviction even when models have active API calls (default: false for safety)" group:"backends"`
100103
SizeAwareEviction bool `env:"LOCALAI_SIZE_AWARE_EVICTION,SIZE_AWARE_EVICTION" default:"false" help:"Evict the largest loaded model first rather than the least-recently-used one, keeping small utility models resident and maximizing freed memory per eviction" group:"backends"`
101104
LRUEvictionMaxRetries int `env:"LOCALAI_LRU_EVICTION_MAX_RETRIES,LRU_EVICTION_MAX_RETRIES" default:"30" help:"Maximum number of retries when waiting for busy models to become idle before eviction (default: 30)" group:"backends"`
@@ -667,6 +670,20 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
667670
opts = append(opts, config.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends))
668671
}
669672

673+
// Per-node VRAM allocation budget. Record it on the ApplicationConfig and,
674+
// fail-open, install it as the process-global xsysinfo default so allocation
675+
// heuristics cap at it. A malformed value must never block startup: it is
676+
// logged and treated as unset (full detected VRAM).
677+
if r.VRAMBudget != "" {
678+
opts = append(opts, config.SetVRAMBudget(r.VRAMBudget))
679+
if b, err := vrambudget.Parse(r.VRAMBudget); err != nil {
680+
xlog.Warn("Ignoring invalid LOCALAI_VRAM_BUDGET", "value", r.VRAMBudget, "error", err)
681+
} else {
682+
xsysinfo.SetDefaultVRAMBudget(b)
683+
xlog.Info("VRAM allocation budget set", "budget", b.String())
684+
}
685+
}
686+
670687
if r.PreloadBackendOnly {
671688
_, err := application.New(opts...)
672689
return err

core/config/application_config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/mudler/LocalAI/pkg/model"
1010
"github.com/mudler/LocalAI/pkg/modelartifacts"
1111
"github.com/mudler/LocalAI/pkg/system"
12+
"github.com/mudler/LocalAI/pkg/vrambudget"
1213
"github.com/mudler/LocalAI/pkg/xsysinfo"
1314
"github.com/mudler/xlog"
1415
)
@@ -133,6 +134,10 @@ type ApplicationConfig struct {
133134
MemoryReclaimerEnabled bool // Enable memory threshold monitoring
134135
MemoryReclaimerThreshold float64 // Threshold 0.0-1.0 (e.g., 0.95 = 95%)
135136

137+
// VRAMBudget optionally caps how much VRAM this instance uses for model
138+
// allocation, as "80%" or "12GB". Empty = use full detected VRAM.
139+
VRAMBudget string
140+
136141
// Eviction settings
137142
ForceEvictionWhenBusy bool // Force eviction even when models have active API calls (default: false for safety)
138143
SizeAwareEviction bool // Evict largest models first rather than least-recently-used (default: false)
@@ -464,6 +469,13 @@ func SetMemoryReclaimerThreshold(threshold float64) AppOption {
464469
}
465470
}
466471

472+
// SetVRAMBudget sets the VRAM allocation cap ("80%" or "12GB", "" = no cap).
473+
func SetVRAMBudget(v string) AppOption {
474+
return func(o *ApplicationConfig) {
475+
o.VRAMBudget = v
476+
}
477+
}
478+
467479
// WithMemoryReclaimer configures the memory reclaimer with the given settings
468480
func WithMemoryReclaimer(enabled bool, threshold float64) AppOption {
469481
return func(o *ApplicationConfig) {
@@ -1124,6 +1136,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
11241136
lruEvictionMaxRetries := o.LRUEvictionMaxRetries
11251137
threads := o.Threads
11261138
contextSize := o.ContextSize
1139+
vramBudget := o.VRAMBudget
11271140
f16 := o.F16
11281141
debug := o.Debug
11291142
tracingMaxItems := o.TracingMaxItems
@@ -1218,6 +1231,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
12181231
LRUEvictionRetryInterval: &lruEvictionRetryInterval,
12191232
Threads: &threads,
12201233
ContextSize: &contextSize,
1234+
VRAMBudget: &vramBudget,
12211235
F16: &f16,
12221236
Debug: &debug,
12231237
TracingMaxItems: &tracingMaxItems,
@@ -1358,6 +1372,15 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
13581372
if settings.ContextSize != nil {
13591373
o.ContextSize = *settings.ContextSize
13601374
}
1375+
if settings.VRAMBudget != nil {
1376+
o.VRAMBudget = *settings.VRAMBudget
1377+
// Live-apply so the cap takes effect without a restart. An empty string
1378+
// clears the cap; a malformed value is rejected by the settings endpoint,
1379+
// but stay fail-open here too so a bad persisted value cannot wedge apply.
1380+
if b, err := vrambudget.Parse(o.VRAMBudget); err == nil {
1381+
xsysinfo.SetDefaultVRAMBudget(b)
1382+
}
1383+
}
13611384
if settings.F16 != nil {
13621385
o.F16 = *settings.F16
13631386
}

core/config/application_config_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,4 +648,11 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
648648
Expect(appConfig.SingleBackend).To(BeFalse()) // 3 != 1, so single backend is false
649649
})
650650
})
651+
652+
Describe("SetVRAMBudget", func() {
653+
It("stores the VRAM budget via SetVRAMBudget", func() {
654+
o := NewApplicationConfig(SetVRAMBudget("80%"))
655+
Expect(o.VRAMBudget).To(Equal("80%"))
656+
})
657+
})
651658
})

core/config/runtime_settings.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,20 @@ type RuntimeSettings struct {
2828

2929
// Eviction settings
3030
ForceEvictionWhenBusy *bool `json:"force_eviction_when_busy,omitempty"` // Force eviction even when models have active API calls (default: false for safety)
31-
SizeAwareEviction *bool `json:"size_aware_eviction,omitempty"` // Evict largest models first rather than least-recently-used (default: false)
31+
SizeAwareEviction *bool `json:"size_aware_eviction,omitempty"` // Evict largest models first rather than least-recently-used (default: false)
3232
LRUEvictionMaxRetries *int `json:"lru_eviction_max_retries,omitempty"` // Maximum number of retries when waiting for busy models to become idle (default: 30)
3333
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)
3434

3535
// Performance settings
36-
Threads *int `json:"threads,omitempty"`
37-
ContextSize *int `json:"context_size,omitempty"`
38-
F16 *bool `json:"f16,omitempty"`
39-
Debug *bool `json:"debug,omitempty"`
40-
EnableTracing *bool `json:"enable_tracing,omitempty"`
41-
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
42-
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
43-
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
36+
Threads *int `json:"threads,omitempty"`
37+
ContextSize *int `json:"context_size,omitempty"`
38+
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
39+
F16 *bool `json:"f16,omitempty"`
40+
Debug *bool `json:"debug,omitempty"`
41+
EnableTracing *bool `json:"enable_tracing,omitempty"`
42+
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
43+
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
44+
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
4445

4546
// Security/CORS settings
4647
CORS *bool `json:"cors,omitempty"`

core/config/runtime_settings_persist_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
. "github.com/onsi/gomega"
1010

1111
"github.com/mudler/LocalAI/core/config"
12+
"github.com/mudler/LocalAI/pkg/vrambudget"
13+
"github.com/mudler/LocalAI/pkg/xsysinfo"
1214
)
1315

1416
func strPtr(s string) *string { return &s }
@@ -93,6 +95,38 @@ var _ = Describe("RuntimeSettings persistence helpers", func() {
9395
})
9496
})
9597

98+
// VRAMBudget round trip pins the Settings-page persistence contract: the
99+
// operator-set cap must survive ToRuntimeSettings (GET /api/settings) ->
100+
// ApplyRuntimeSettings (POST /api/settings) so it lives past a save, and an
101+
// empty value must clear the cap rather than being dropped.
102+
Describe("VRAMBudget round trip", func() {
103+
// ApplyRuntimeSettings live-applies the cap through the process-global
104+
// xsysinfo.SetDefaultVRAMBudget. Reset it after each spec so a cap set
105+
// here cannot bleed into other core/config specs under Ginkgo's
106+
// randomized ordering.
107+
AfterEach(func() {
108+
xsysinfo.SetDefaultVRAMBudget(vrambudget.Budget{})
109+
})
110+
111+
It("round-trips the VRAM budget", func() {
112+
o := config.NewApplicationConfig(config.SetVRAMBudget("80%"))
113+
rs := o.ToRuntimeSettings()
114+
Expect(rs.VRAMBudget).NotTo(BeNil())
115+
Expect(*rs.VRAMBudget).To(Equal("80%"))
116+
117+
o2 := config.NewApplicationConfig()
118+
o2.ApplyRuntimeSettings(&rs)
119+
Expect(o2.VRAMBudget).To(Equal("80%"))
120+
})
121+
122+
It("applies an empty VRAM budget as clearing the cap", func() {
123+
o := config.NewApplicationConfig(config.SetVRAMBudget("80%"))
124+
empty := ""
125+
o.ApplyRuntimeSettings(&config.RuntimeSettings{VRAMBudget: &empty})
126+
Expect(o.VRAMBudget).To(Equal(""))
127+
})
128+
})
129+
96130
// MITM round trip pins the contract that loadRuntimeSettingsFromFile
97131
// MITM listener address must survive a write/read round trip so the
98132
// next process restart can bring the listener back up. (Intercept

core/http/endpoints/localai/nodes.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
3131
"github.com/mudler/LocalAI/pkg/httpclient"
3232
"github.com/mudler/LocalAI/pkg/natsauth"
33+
"github.com/mudler/LocalAI/pkg/vrambudget"
3334
)
3435

3536
// nodeError builds a schema.ErrorResponse for node endpoints.
@@ -92,6 +93,9 @@ type RegisterNodeRequest struct {
9293
// Workers older than this field omit it; we coerce 0 → 1 below to preserve
9394
// historical single-replica behavior.
9495
MaxReplicasPerModel int `json:"max_replicas_per_model,omitempty"`
96+
// VRAMBudget is the worker's operator-set VRAM cap ("80%" or "12GB"). The
97+
// registry resolves and enforces it against the raw reported VRAM.
98+
VRAMBudget string `json:"vram_budget,omitempty"`
9599
}
96100

97101
// RegisterNodeEndpoint registers a new backend node.
@@ -171,6 +175,7 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
171175
GPUVendor: req.GPUVendor,
172176
GPUComputeCapability: req.GPUComputeCapability,
173177
MaxReplicasPerModel: maxReplicasPerModel,
178+
VRAMBudget: req.VRAMBudget,
174179
}
175180

176181
ctx := c.Request().Context()
@@ -942,6 +947,74 @@ func ResetMaxReplicasPerModelEndpoint(registry *nodes.NodeRegistry) echo.Handler
942947
}
943948
}
944949

950+
// UpdateVRAMBudgetRequest is the body for the per-node VRAM budget endpoint.
951+
type UpdateVRAMBudgetRequest struct {
952+
// Value is the VRAM cap ("80%" or "12GB"). Empty string clears the cap.
953+
Value string `json:"value"`
954+
}
955+
956+
// UpdateVRAMBudgetEndpoint sets a node's VRAM allocation cap as a sticky admin
957+
// override. The value is validated, resolved against the node's total VRAM, and
958+
// applied to available_vram immediately so scheduling reflects it before the
959+
// next heartbeat.
960+
//
961+
// @Summary Update a node's VRAM allocation budget
962+
// @Tags Nodes
963+
// @Param id path string true "Node ID"
964+
// @Param request body UpdateVRAMBudgetRequest true "New value (\"80%\" or \"12GB\"; empty clears)"
965+
// @Success 200 {object} map[string]string
966+
// @Failure 400 {object} map[string]any "invalid budget"
967+
// @Failure 404 {object} map[string]any "node not found"
968+
// @Router /api/nodes/{id}/vram-budget [put]
969+
func UpdateVRAMBudgetEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
970+
return func(c echo.Context) error {
971+
ctx := c.Request().Context()
972+
nodeID := c.Param("id")
973+
if _, err := registry.Get(ctx, nodeID); err != nil {
974+
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
975+
}
976+
var req UpdateVRAMBudgetRequest
977+
if err := c.Bind(&req); err != nil {
978+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "invalid request body"))
979+
}
980+
// An empty value clears the cap; only a non-empty value must parse.
981+
if req.Value != "" {
982+
if _, err := vrambudget.Parse(req.Value); err != nil {
983+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, fmt.Sprintf("invalid vram budget: %v", err)))
984+
}
985+
}
986+
if err := registry.UpdateVRAMBudget(ctx, nodeID, req.Value); err != nil {
987+
xlog.Error("Failed to update vram_budget", "node", nodeID, "value", req.Value, "error", err)
988+
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to update vram budget"))
989+
}
990+
return c.JSON(http.StatusOK, map[string]string{"vram_budget": req.Value})
991+
}
992+
}
993+
994+
// ResetVRAMBudgetEndpoint clears the admin override so the worker's
995+
// LOCALAI_VRAM_BUDGET takes over again on next registration.
996+
//
997+
// @Summary Reset a node's VRAM budget to the worker default
998+
// @Tags Nodes
999+
// @Param id path string true "Node ID"
1000+
// @Success 200 {object} map[string]bool
1001+
// @Failure 404 {object} map[string]any "node not found"
1002+
// @Router /api/nodes/{id}/vram-budget [delete]
1003+
func ResetVRAMBudgetEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
1004+
return func(c echo.Context) error {
1005+
ctx := c.Request().Context()
1006+
nodeID := c.Param("id")
1007+
if _, err := registry.Get(ctx, nodeID); err != nil {
1008+
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
1009+
}
1010+
if err := registry.ResetVRAMBudget(ctx, nodeID); err != nil {
1011+
xlog.Error("Failed to reset vram_budget override", "node", nodeID, "error", err)
1012+
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to reset vram budget"))
1013+
}
1014+
return c.JSON(http.StatusOK, map[string]bool{"reset": true})
1015+
}
1016+
}
1017+
9451018
// SetNodeLabelsEndpoint replaces all labels for a node.
9461019
func SetNodeLabelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
9471020
return func(c echo.Context) error {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package localai
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
9+
"github.com/labstack/echo/v4"
10+
"github.com/mudler/LocalAI/core/services/nodes"
11+
"github.com/mudler/LocalAI/core/services/testutil"
12+
13+
. "github.com/onsi/ginkgo/v2"
14+
. "github.com/onsi/gomega"
15+
)
16+
17+
var _ = Describe("Node VRAM budget HTTP handlers", func() {
18+
var registry *nodes.NodeRegistry
19+
20+
BeforeEach(func() {
21+
db := testutil.SetupTestDB()
22+
var err error
23+
registry, err = nodes.NewNodeRegistry(db)
24+
Expect(err).ToNot(HaveOccurred())
25+
})
26+
27+
// seedHealthyNode registers a healthy node with the given total/available
28+
// VRAM and returns its ID.
29+
seedHealthyNode := func(ctx context.Context, total, available uint64) string {
30+
node := &nodes.BackendNode{
31+
ID: "vram-node",
32+
Name: "vram-node",
33+
Address: "10.0.0.9:50051",
34+
Status: nodes.StatusHealthy,
35+
TotalVRAM: total,
36+
AvailableVRAM: available,
37+
}
38+
Expect(registry.Register(ctx, node, true)).To(Succeed())
39+
return node.ID
40+
}
41+
42+
// doPUT drives UpdateVRAMBudgetEndpoint for the given node ID and body.
43+
doPUT := func(id, body string) *httptest.ResponseRecorder {
44+
e := echo.New()
45+
req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(body))
46+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
47+
rec := httptest.NewRecorder()
48+
c := e.NewContext(req, rec)
49+
c.SetParamNames("id")
50+
c.SetParamValues(id)
51+
Expect(UpdateVRAMBudgetEndpoint(registry)(c)).To(Succeed())
52+
return rec
53+
}
54+
55+
// doDELETE drives ResetVRAMBudgetEndpoint for the given node ID.
56+
doDELETE := func(id string) *httptest.ResponseRecorder {
57+
e := echo.New()
58+
req := httptest.NewRequest(http.MethodDelete, "/", nil)
59+
rec := httptest.NewRecorder()
60+
c := e.NewContext(req, rec)
61+
c.SetParamNames("id")
62+
c.SetParamValues(id)
63+
Expect(ResetVRAMBudgetEndpoint(registry)(c)).To(Succeed())
64+
return rec
65+
}
66+
67+
It("sets and clears a node's VRAM budget", func(ctx SpecContext) {
68+
id := seedHealthyNode(ctx, 16_000_000_000, 16_000_000_000)
69+
70+
rec := doPUT(id, `{"value":"50%"}`)
71+
Expect(rec.Code).To(Equal(http.StatusOK))
72+
node, err := registry.Get(ctx, id)
73+
Expect(err).ToNot(HaveOccurred())
74+
Expect(node.VRAMBudget).To(Equal("50%"))
75+
Expect(node.AvailableVRAM).To(Equal(uint64(8_000_000_000)))
76+
77+
rec = doDELETE(id)
78+
Expect(rec.Code).To(Equal(http.StatusOK))
79+
node, err = registry.Get(ctx, id)
80+
Expect(err).ToNot(HaveOccurred())
81+
Expect(node.VRAMBudget).To(Equal(""))
82+
})
83+
84+
It("rejects a malformed budget with 400", func(ctx SpecContext) {
85+
id := seedHealthyNode(ctx, 16_000_000_000, 16_000_000_000)
86+
rec := doPUT(id, `{"value":"120%"}`)
87+
Expect(rec.Code).To(Equal(http.StatusBadRequest))
88+
})
89+
90+
It("returns 404 for an unknown node on update", func(ctx SpecContext) {
91+
rec := doPUT("does-not-exist", `{"value":"50%"}`)
92+
Expect(rec.Code).To(Equal(http.StatusNotFound))
93+
})
94+
95+
It("returns 404 for an unknown node on reset", func(ctx SpecContext) {
96+
rec := doDELETE("does-not-exist")
97+
Expect(rec.Code).To(Equal(http.StatusNotFound))
98+
})
99+
})

0 commit comments

Comments
 (0)