Health: learn n_ctx and total_slots from /props

Implemented /props learning in internal/health/health.go (added Status.NCtx/Status.Slots, PerSlotCtx, and a best-effort GET <base>/props appended to the poll after /v1/models; 0/unknown on any failure without failing the poll) and exposed them in internal/admin/admin.go HostView. Copied internal/health/props_test.go and the replacement internal/proxy/helpers_test.go byte-identical to docs/plans/v2/_files/.

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 09:14:08 -07:00
parent f73ffb0d27
commit 056a527fd7
5 changed files with 154 additions and 3 deletions
+69 -3
View File
@@ -28,6 +28,17 @@ type Status struct {
LastOK time.Time `json:"last_ok"` // zero if never
LastErr string `json:"last_err"` // "" after a good poll
Consecutive int `json:"consecutive"` // good polls in a row
NCtx int `json:"n_ctx"` // total context from /props; 0 = unknown
Slots int `json:"slots"` // total_slots from /props; 0 = unknown
}
// PerSlotCtx is the context one request may use: NCtx divided by Slots, or the
// whole NCtx when Slots is unknown (0). It is 0 when NCtx is unknown.
func (s Status) PerSlotCtx() int {
if s.NCtx == 0 || s.Slots == 0 {
return s.NCtx
}
return s.NCtx / s.Slots
}
type entry struct {
@@ -40,6 +51,8 @@ type pollResult struct {
cancelled bool
reason string
loaded []string
nctx int
slots int
}
// Table maps a host name to its health status. All methods are safe for concurrent use.
@@ -156,6 +169,8 @@ func (t *Table) pollHost(ctx context.Context, name string) {
e.status.LastOK = time.Now()
e.status.LastErr = ""
e.status.Loaded = r.loaded
e.status.NCtx = r.nctx
e.status.Slots = r.slots
e.status.Healthy = !e.everFailed || e.status.Consecutive >= RecoveryPolls
} else {
e.everFailed = true
@@ -174,13 +189,64 @@ func (t *Table) poll(ctx context.Context, base string) pollResult {
return r
}
loaded, r = t.check(ctx, base+"/v1/models", "models")
if r.cancelled {
if r.cancelled || r.reason != "" {
return r
}
if r.reason != "" {
nctx, slots, r := t.props(ctx, base)
if r.cancelled || r.reason != "" {
return r
}
return pollResult{ok: true, loaded: loaded}
return pollResult{ok: true, loaded: loaded, nctx: nctx, slots: slots}
}
// props reads <base>/props best-effort. A request that fails because ctx is
// done yields a cancelled result so the caller records nothing; any other
// outcome (status, body, or missing fields) leaves context unknown without
// failing the poll.
func (t *Table) props(ctx context.Context, base string) (int, int, pollResult) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/props", nil)
if err != nil {
if ctx.Err() != nil {
return 0, 0, pollResult{cancelled: true}
}
return 0, 0, pollResult{}
}
resp, err := t.client.Do(req)
if err != nil {
if ctx.Err() != nil {
return 0, 0, pollResult{cancelled: true}
}
return 0, 0, pollResult{}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, 0, pollResult{}
}
var p struct {
Generation struct {
NCtx *int `json:"n_ctx"`
} `json:"default_generation_settings"`
TotalSlots *int `json:"total_slots"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, MaxModelsBody)).Decode(&p); err != nil {
return 0, 0, pollResult{}
}
nctx, slots := 0, 0
if p.Generation.NCtx != nil {
nctx = *p.Generation.NCtx
}
if p.TotalSlots != nil {
slots = *p.TotalSlots
}
if nctx < 0 {
nctx = 0
}
if slots < 0 {
slots = 0
}
return nctx, slots, pollResult{}
}
// check performs one GET and, on success, returns the decoded model ids. Health checks use the