llama.cpp is what makes a cluster with no cards useful: it serves the same OpenAI API on CPU, so the whole path — placement, supervision, scraping, scheduling, admission — can be exercised end to end on ordinary machines. Three things had to give way for it. A profile may now omit its GPU class, and that is how it says it runs on the CPU. Only engines that can actually serve without a card may omit it; for vLLM an omitted class stays an error, because otherwise a typo becomes a replica placed on a machine with no hardware and diagnosed minutes later during model loading. Placement spreads CPU replicas instead of bin packing them, since unlike GPUs there is no allocation to stop two of them sharing a machine's cores. Metric mappings are now per engine rather than per cluster. llama.cpp calls the same three numbers requests_processing, requests_deferred and kv_cache_usage_ratio, and a single global mapping would leave half a mixed fleet unmeasurable. An empty mapping still defaults to vLLM's names; one that names its own Running metric is taken as a complete statement, so the fields it leaves blank stay blank rather than being filled with names that engine cannot have. The adapter refuses multi-node and tensor-parallel profiles. llama.cpp splits across local GPUs by a different mechanism than this adapter drives, and accepting either would produce a replica that starts and then serves from one machine while the catalog believes it has several. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
4.8 KiB
Go
184 lines
4.8 KiB
Go
// Package control wires the control plane together: inventory, catalog,
|
|
// placement and reconciliation, and publishes the result to the router.
|
|
package control
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
|
)
|
|
|
|
// EndpointSink receives the endpoint list. The router implements it.
|
|
//
|
|
// It is an interface so the control plane does not depend on the router: when
|
|
// the two split into separate processes, this becomes a watch stream and
|
|
// nothing above it changes (plan §3.1).
|
|
type EndpointSink interface {
|
|
SetEndpoints(ctx context.Context, infos []endpoint.Info)
|
|
}
|
|
|
|
// Config tunes the control plane.
|
|
type Config struct {
|
|
Reconciler reconciler.Config
|
|
// PublishInterval is how often the endpoint list is republished even when
|
|
// nothing appears to have changed. Events drive the common case; this is
|
|
// the backstop.
|
|
PublishInterval time.Duration
|
|
}
|
|
|
|
// DefaultConfig returns sensible defaults.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Reconciler: reconciler.DefaultConfig(),
|
|
PublishInterval: time.Second,
|
|
}
|
|
}
|
|
|
|
// Controller owns the control plane's state.
|
|
type Controller struct {
|
|
cfg Config
|
|
log *slog.Logger
|
|
inv *inventory.Store
|
|
cat *catalog.Catalog
|
|
rec *reconciler.Reconciler
|
|
sink EndpointSink
|
|
|
|
mu sync.Mutex
|
|
last string // fingerprint of the last published list
|
|
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
// New builds a controller.
|
|
func New(cfg Config, cat *catalog.Catalog, disp reconciler.Dispatcher, sink EndpointSink, log *slog.Logger) *Controller {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
if cfg.PublishInterval <= 0 {
|
|
cfg.PublishInterval = time.Second
|
|
}
|
|
inv := inventory.NewStore()
|
|
return &Controller{
|
|
cfg: cfg,
|
|
log: log,
|
|
inv: inv,
|
|
cat: cat,
|
|
rec: reconciler.New(cfg.Reconciler, cat, inv, disp, log),
|
|
sink: sink,
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Inventory exposes the node inventory, which the agent service feeds.
|
|
func (c *Controller) Inventory() *inventory.Store { return c.inv }
|
|
|
|
// Reconciler exposes the reconciliation loop, for status reports and ctl.
|
|
func (c *Controller) Reconciler() *reconciler.Reconciler { return c.rec }
|
|
|
|
// Catalog exposes the model catalog.
|
|
func (c *Controller) Catalog() *catalog.Catalog { return c.cat }
|
|
|
|
// Start runs the reconciler and begins publishing endpoints.
|
|
func (c *Controller) Start(ctx context.Context) {
|
|
c.rec.Start(ctx)
|
|
|
|
go func() {
|
|
defer close(c.done)
|
|
t := time.NewTicker(c.cfg.PublishInterval)
|
|
defer t.Stop()
|
|
|
|
c.Publish(ctx)
|
|
for {
|
|
select {
|
|
case <-c.stop:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
c.Publish(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Close stops the control plane.
|
|
func (c *Controller) Close() {
|
|
c.once.Do(func() {
|
|
close(c.stop)
|
|
<-c.done
|
|
c.rec.Close()
|
|
})
|
|
}
|
|
|
|
// Publish pushes the current Ready replicas to the sink.
|
|
//
|
|
// It is exported so tests can drive it deterministically instead of racing a
|
|
// ticker, and it skips the push when nothing changed: replacing the list is
|
|
// cheap but resynchronizes the metric pollers, and doing that every second for
|
|
// no reason adds churn to every engine's /metrics.
|
|
func (c *Controller) Publish(ctx context.Context) {
|
|
infos := c.endpoints()
|
|
|
|
fp := fingerprint(infos)
|
|
c.mu.Lock()
|
|
unchanged := fp == c.last
|
|
c.last = fp
|
|
c.mu.Unlock()
|
|
if unchanged {
|
|
return
|
|
}
|
|
|
|
c.log.Info("publishing endpoints", "count", len(infos))
|
|
c.sink.SetEndpoints(ctx, infos)
|
|
}
|
|
|
|
// endpoints converts Ready replicas into the router's view.
|
|
func (c *Controller) endpoints() []endpoint.Info {
|
|
ready := c.rec.ReadyReplicas()
|
|
out := make([]endpoint.Info, 0, len(ready))
|
|
|
|
for _, rep := range ready {
|
|
m, ok := c.cat.Resolve(rep.Model)
|
|
if !ok {
|
|
// The model left the catalog while a replica was still running.
|
|
// The reconciler will tear it down; until then, do not route to it.
|
|
continue
|
|
}
|
|
// Clients may address a model by any of its names, and the router
|
|
// matches endpoints by the resolved name plus aliases.
|
|
names := append([]string{m.Name}, m.Aliases...)
|
|
|
|
prof, _ := m.ProfileByName(rep.Profile)
|
|
out = append(out, endpoint.Info{
|
|
ID: rep.ID,
|
|
URL: strings.TrimRight(rep.Endpoint, "/"),
|
|
Models: names,
|
|
Engine: prof.Engine,
|
|
Pool: rep.Pool,
|
|
Weight: rep.Weight,
|
|
Priority: prof.Priority,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fingerprint summarizes a list so an unchanged one can be skipped.
|
|
func fingerprint(infos []endpoint.Info) string {
|
|
var b strings.Builder
|
|
for _, i := range infos {
|
|
fmt.Fprintf(&b, "%s|%s|%s|%s|%v|%g|%d;", i.ID, i.URL, i.Engine, i.Pool, i.Models, i.Weight, i.Priority)
|
|
}
|
|
return b.String()
|
|
}
|