Files
llmgw/internal/router/router.go
T
Ilker ManapandClaude Opus 5.5 4a4fe8fa16 Pictures are priced by the picture
auth.pricing takes per_image, charged on top of a prompt's tokens; a model
priced only per picture is not free, so a spent budget still stops it.
Each usage row keeps how many pictures were made (migration 0041), and
ctl request and the admin API's request view show them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:48:47 +02:00

1287 lines
44 KiB
Go

// Package router wires the pieces into a working OpenAI-compatible gateway:
// resolve the model, pick an endpoint, proxy the stream, retry when it is safe.
//
// The sub-packages hold the reusable parts (scheduler, endpoint, scrape, proxy,
// breaker); this package holds the orchestration and the HTTP surface.
package router
import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"log/slog"
"math"
"net"
"net/http"
"net/netip"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/admission"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/breaker"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/kvindex"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/prefix"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/scheduler"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/scrape"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/web"
)
// Router serves the OpenAI API in front of a set of engine replicas.
type Router struct {
cfg config.Router
log *slog.Logger
reg *endpoint.Registry
poller *scrape.Poller
px *proxy.Proxy
// comfy asks ComfyUI replicas for pictures.
comfy *comfy.Client
m *metrics
// adm is nil when admission control is disabled.
adm *admission.Controller
profiles map[string]*scheduler.Profile
// models maps every name a client may use — canonical names and aliases —
// onto its catalog entry.
//
// Held atomically because the catalog is no longer fixed at startup: adding
// a model used to mean restarting the control plane, and this is the
// router's half of not having to. A request takes the map once and works
// from it, so a swap mid-request is invisible rather than a race.
models atomic.Pointer[map[string]catalog.Model]
// adapters are the LoRA adapters by the name clients ask for them by.
adapters atomic.Pointer[map[string]adapterRef]
// canaryTargets maps each model that is another's canary to that one;
// canaries counts how the two sides served.
canaryTargets atomic.Pointer[map[string]string]
canaries canaryStats
// slo keeps each model's recent token timings, for admission.
slo sloTracker
// batchWake has the batch worker look for work at once.
batchWake chan struct{}
// reqLogs is each project's keeping of requests, as last read.
reqLogs requestLogs
// answers are the answers projects reuse; nil reuses none.
answers *answerCache
// extraMetrics are collectors other components publish on /metrics.
extraMetrics []prometheus.Collector
// activator loads a model that is configured but not currently running.
activator Activator
// metricsHandler is built with the admin registry and reused by any
// listener that should serve metrics without serving pprof.
metricsHandler http.Handler
// landing configures the public page, which the router serves but does not
// otherwise care about.
landing config.Landing
// auth, store, acct, recorder and accounts are nil when authentication is
// disabled.
auth *auth.Authenticator
accounts *account.Service
store *storepkg.DB
// usageRetention is how long request records are kept; zero is for ever.
usageRetention time.Duration
// isLeader says whether this process leads; nil is always.
isLeader atomic.Pointer[func() bool]
// federation is which cluster this is, to its peers; nil until said.
federation atomic.Pointer[federationID]
acct *accounting.Accountant
// guard judges requests and answers; nil judges nothing. guardLibrary
// is the policies it was built from, for the pages that show them.
guard guard.Guard
guardLibrary func() guard.Config
// oidc is sign-in through an identity provider, and scim provisioning
// by it.
oidc config.OIDC
// mail sends what the portal mails: a password reset link.
mail mail.Config
// stopped is the operators having stopped the service (llmgw stop).
stopped atomic.Bool
scim config.SCIM
// threads holds conversations and memory notes. It is store when auth is on,
// its own file when only the threads block is configured, and nil when
// nothing is kept. Separate from store because a non-nil store also switches
// on the accounts, web pages and usage recording, none of which belong to a
// gateway that only wants to remember things.
threads *storepkg.DB
// anonymous means threads has no project to scope to: everything shares the
// one pool named by anonymousProject.
anonymous bool
// personalOff refuses personal use: the chat page then only offers the
// projects a person is a member of.
personalOff bool
// personalDefaults is what personal use is held to when nobody wrote a
// limit for the person, for the quota page to say so.
personalDefaults *storepkg.Limits
// llamaVersion is the llama.cpp build the nodes install, so the mirror page
// can refuse weights that build cannot load before fetching them.
llamaVersion string
// ownsThreads is true when threads is not store and so must be closed here.
ownsThreads bool
recorder *accounting.Recorder
// prefix scores by prefix: the precise index for models with kv_events,
// the approximate tree when router.prefix is on. Its tree is nil when it
// is off.
prefix *scheduler.PrefixScorer
// lora knows which replicas hold which adapters that load on demand.
lora *loraTracker
// kv is the precise index (plan §7.5 tier 2), fed by the control plane
// from the agents' KV cache events; kvClient makes its tokenize calls.
kv *kvindex.Index
kvClient *http.Client
// clusterView is nil until the control plane hands it over, and stays nil
// in a router-only deployment.
clusterView web.Source
clusterUsage web.UsageSource
// mirror is nil unless a model mirror is configured, which leaves
// /admin/mirror unmounted — the right page for a deployment with no mirror
// is no page, not an empty one pointing at nothing.
mirror web.MirrorAdmin
// registry browses a model registry from the mirror page. Nil where the
// deployment has no way out to the internet.
registry web.Registry
// catalogAdmin puts a mirrored model into service. Nil without a store,
// where a model added at runtime would not survive a restart.
catalogAdmin web.CatalogAdmin
// history is the metrics store the dashboard charts read from. Nil where
// none is configured, which leaves the charts off the page.
history web.History
histWindow time.Duration
// kbDefCache remembers each caller's default knowledge-base
// collections for kbDefaultTTL.
kbDefMu sync.Mutex
kbDefCache map[string]kbDefaultEntry
// settingsCheck and settingsLocal mount the portal's settings page.
settingsCheck func([]byte) error
settingsLocal []string
}
// SetClusterView gives the router the data the /admin page renders.
//
// Set by the server command once the control plane exists, because the router
// is built first. Nil leaves /admin unmounted, which is what a router-only
// deployment should have: no cluster to show.
func (r *Router) SetClusterView(src web.Source, usage web.UsageSource) {
r.clusterView, r.clusterUsage = src, usage
}
// SetMirror gives the router a model mirror to administer.
//
// Set by the server command when one is configured, for the same reason as the
// cluster view: the router is built before the things its admin pages show.
func (r *Router) SetMirror(m web.MirrorAdmin) { r.mirror = m }
// SetGuard has every request and answer judged by g.
func (r *Router) SetGuard(g guard.Guard) { r.guard = g }
// SetGuardLibrary is the policies the guard was built from, for the pages.
func (r *Router) SetGuardLibrary(c func() guard.Config) { r.guardLibrary = c }
// SetSettingsCheck enables the portal's settings page: check says whether a
// document would load as the cluster's settings beside this machine's own
// server.yaml, and local are the keys that file keeps.
func (r *Router) SetSettingsCheck(check func([]byte) error, local []string) {
r.settingsCheck, r.settingsLocal = check, local
}
// SetStopped refuses, or again serves, every request for a model.
func (r *Router) SetStopped(stopped bool) { r.stopped.Store(stopped) }
// SetRegistry gives the mirror page somewhere to look models up.
func (r *Router) SetRegistry(reg web.Registry) { r.registry = reg }
// SetCatalogAdmin lets the mirror page put a model into service.
func (r *Router) SetCatalogAdmin(c web.CatalogAdmin) { r.catalogAdmin = c }
// SetHistory gives the dashboard somewhere to read the cluster's past.
func (r *Router) SetHistory(h web.History, window time.Duration) {
r.history, r.histWindow = h, window
}
// Auth exposes the authenticator so ctl and the batch writer can reach it.
// Nil when authentication is off.
func (r *Router) Auth() *auth.Authenticator { return r.auth }
// Store exposes the key store, or nil when authentication is off.
func (r *Router) Store() *storepkg.DB { return r.store }
// New builds a router from configuration. Start must be called to begin scraping.
func New(cfg config.Server, log *slog.Logger) (*Router, error) {
if log == nil {
log = slog.Default()
}
reg := endpoint.NewRegistry(endpoint.Config{
StaleAfter: cfg.Router.StaleAfter,
Breaker: breaker.Config{
FailureThreshold: cfg.Router.FailureThreshold,
BaseCooldown: cfg.Router.Cooldown,
},
})
reg.Replace(toInfos(cfg.Endpoints))
sc := scheduler.ProfileConfig{
TargetWaiting: cfg.Router.Scoring.TargetWaiting,
TargetInflight: cfg.Router.Scoring.TargetInflight,
KVCeiling: cfg.Router.Scoring.KVCeiling,
SessionLoadFactor: cfg.Router.Scoring.SessionLoadFactor,
}
// The metrics come first because the prefix scorer reports into them, and
// the decision metric is the whole difference between tuning this and
// guessing at it.
metrics := newMetrics()
metrics.tenant.level = cfg.Router.TenantLabel
metrics.tenant.setTagLabels(cfg.Router.TagLabels)
// The scorer exists whether or not the tree does: which models use the
// precise index is the catalog's to say, and the catalog changes while
// the router runs. With neither in use it scores nothing and records
// nothing.
var tree *prefix.Tree
if cfg.Router.Prefix.Enabled {
tree = prefix.New(prefix.Config{
MaxNodes: cfg.Router.Prefix.MaxNodes,
MinMatch: cfg.Router.Prefix.MinMatch,
TTL: cfg.Router.Prefix.TTL,
})
}
minBlocks := cfg.Router.KVIndex.MinBlocks
if minBlocks <= 0 {
minBlocks = 4
}
prefixScorer := scheduler.NewPrefixScorer(tree,
prefix.Thresholds{
MatchRatio: cfg.Router.Prefix.MatchRatio,
BalanceAbs: cfg.Router.Prefix.BalanceAbs,
BalanceRel: cfg.Router.Prefix.BalanceRel,
},
metrics.observePrefix).WithKV(minBlocks, metrics.observeKV)
sc.Prefix = prefixScorer
profiles := map[string]*scheduler.Profile{}
for _, name := range []string{"chat", "batch", "long-context", "debug"} {
profiles[name] = scheduler.Builtin(name, sc)
}
if _, ok := profiles[cfg.Router.DefaultProfile]; !ok {
return nil, fmt.Errorf("unknown default_profile %q", cfg.Router.DefaultProfile)
}
// The portal's language for whoever neither chose one nor asked for one
// it speaks. Checked here so a mistyped code stops the server starting
// rather than quietly serving English.
if err := web.SetDefaultLanguage(cfg.Landing.Language); err != nil {
return nil, fmt.Errorf("landing.language: %w", err)
}
r := &Router{
batchWake: make(chan struct{}, 1),
cfg: cfg.Router,
landing: cfg.Landing,
log: log,
reg: reg,
profiles: profiles,
prefix: prefixScorer,
lora: newLoraTracker(log, metrics.observeAdapter),
kv: kvindex.New(),
kvClient: newKVClient(),
m: metrics,
comfy: &comfy.Client{HTTP: &http.Client{Transport: &http.Transport{DialContext: (&net.Dialer{Timeout: cfg.Router.Timeouts.Dial}).DialContext}}},
px: proxy.New(proxy.Config{
DialTimeout: cfg.Router.Timeouts.Dial,
ResponseHeaderTimeout: cfg.Router.Timeouts.ResponseHeader,
GenerationTimeout: cfg.Router.Timeouts.Generation,
FirstTokenTimeout: cfg.Router.Timeouts.FirstToken,
IdleTimeout: cfg.Router.Timeouts.Idle,
TotalTimeout: cfg.Router.Timeouts.Total,
MaxResponseBytes: cfg.Router.MaxBodyBytes,
}),
}
r.answers = newAnswerCache(cfg.Router.Cache.MaxBytes, cfg.Router.Cache.MaxEntryBytes,
func(bytes int64, n int) {
metrics.cacheBytes.Set(float64(bytes))
metrics.cacheEntries.Set(float64(n))
})
r.poller = scrape.NewPoller(reg, scrape.Config{
Interval: cfg.Router.ScrapeInterval,
Timeout: cfg.Router.ScrapeTimeout,
}, log)
if !cfg.Auth.Enabled && !loopbackOnly(cfg.Listen) {
log.Warn("auth is off and the API listens beyond this machine: anyone who can reach "+
cfg.Listen+" can use every model; set auth.enabled, or listen on 127.0.0.1",
"listen", cfg.Listen)
}
if cfg.Auth.Enabled {
st, err := storepkg.Open(context.Background(), cfg.Auth.Store)
if err != nil {
return nil, fmt.Errorf("auth store: %w", err)
}
r.store = st
r.usageRetention = cfg.Auth.UsageRetention
// Applied where no scope set one, so a project created without limits
// is still metered. Zero leaves it unlimited, which was the old
// behaviour and is now a choice rather than an omission.
var defRPM, defTPM *int
if cfg.Auth.DefaultRPM > 0 {
defRPM = &cfg.Auth.DefaultRPM
}
if cfg.Auth.DefaultTPM > 0 {
defTPM = &cfg.Auth.DefaultTPM
}
st.SetDefaultLimits(defRPM, defTPM)
st.SetPersonalDefaults(personalLimits(cfg.Auth.Personal))
r.personalOff = cfg.Auth.Personal.Disabled
r.oidc = cfg.Auth.OIDC
r.mail = cfg.Mail
r.scim = cfg.Auth.SCIM
r.personalDefaults = personalLimits(cfg.Auth.Personal)
r.llamaVersion = cfg.WantedEngines()["llamacpp"]
r.auth = auth.New(st, auth.Config{
TTL: cfg.Auth.CacheTTL,
NegativeTTL: cfg.Auth.NegativeTTL,
})
prices := make(map[string]accounting.Price, len(cfg.Auth.Pricing))
for name, p := range cfg.Auth.Pricing {
prices[name] = accounting.Price{
PromptPer1k: p.PromptPer1k, CompletionPer1k: p.CompletionPer1k, PerImage: p.PerImage,
}
}
r.recorder = accounting.NewRecorder(st, r.auth, accounting.RecorderConfig{
FlushInterval: cfg.Auth.FlushInterval,
BatchSize: cfg.Auth.BatchSize,
Capacity: cfg.Auth.BufferSize,
}, log)
r.accounts = account.New(st, account.Config{
SignupOpen: cfg.Auth.SignupOpen,
}, log)
r.acct = accounting.New(
accounting.NewPricing(prices, accounting.Price{
PromptPer1k: cfg.Auth.DefaultPrice.PromptPer1k,
CompletionPer1k: cfg.Auth.DefaultPrice.CompletionPer1k,
PerImage: cfg.Auth.DefaultPrice.PerImage,
}),
r.recorder, log)
r.threads = r.store
} else if cfg.Threads.Store != "" {
st, err := storepkg.Open(context.Background(), cfg.Threads.Store)
if err != nil {
return nil, fmt.Errorf("threads store: %w", err)
}
if err := st.EnsureAnonymousProject(context.Background(), anonymousProject, cfg.Threads.RetainHours); err != nil {
_ = st.Close()
return nil, fmt.Errorf("threads store: %w", err)
}
r.threads, r.anonymous, r.ownsThreads = st, true, true
}
if cfg.Router.Admission.Enabled {
adm, err := admission.New(admissionConfig(cfg.Router), r.saturation)
if err != nil {
return nil, fmt.Errorf("admission: %w", err)
}
r.adm = adm
}
// Seeded from configuration, not only pushed. A router running without a
// control plane — a static endpoint list, which is a supported mode — has
// nothing to push it a catalog, and would otherwise resolve no model at
// all. A control plane pushes over the top of this at startup and on every
// change after.
if len(cfg.Models) > 0 {
r.storeModels(cfg.Models)
}
return r, nil
}
func admissionConfig(rc config.Router) admission.Config {
bands := make([]admission.Band, 0, len(rc.Admission.Bands))
for _, b := range rc.Admission.Bands {
bands = append(bands, admission.Band{
Name: b.Name,
Priority: b.Priority,
SaturationCeiling: b.SaturationCeiling,
})
}
return admission.Config{
Bands: bands,
DefaultBand: rc.Admission.DefaultBand,
Thresholds: admission.Thresholds{
// Admission and the queue scorer must agree on what a healthy
// buffer is, or the two layers fight each other.
TargetWaitingPerReplica: rc.Scoring.TargetWaiting,
KVCeiling: rc.Scoring.KVCeiling,
},
QueueTTL: rc.Admission.QueueTTL,
QueueCapacityPerBand: rc.Admission.QueueCapacityPerBand,
QueueCapacityTotal: rc.Admission.QueueCapacityTotal,
Interval: rc.Admission.Interval,
}
}
// saturation reports the pool pressure for a model, for the admission layer.
//
// outstanding is what admission has let through. It is compared against the
// registry's inflight counters rather than the scraped engine stats, because
// inflight updates the instant a request is dispatched while scraped stats lag
// by a whole interval. Comparing against scraped data would make every
// dispatched-but-unscraped request look like queue depth and throttle the
// steady state down to a few requests per scrape.
//
// What is left over — admitted but not yet dispatched — is the thundering herd:
// a burst that passed the check together before any of it became visible.
func (r *Router) saturation(model string, outstanding int) (float64, bool) {
eps := r.reg.Snapshots(model)
if len(eps) == 0 {
return 0, false
}
dispatched := 0
for _, e := range eps {
dispatched += e.Inflight
}
sat := admission.Saturation(eps, max(0, outstanding-dispatched), admission.Thresholds{
TargetWaitingPerReplica: r.cfg.Scoring.TargetWaiting,
KVCeiling: r.cfg.Scoring.KVCeiling,
})
// A model with an SLO is as saturated as it is past it: the queue and
// the cache can look healthy while answers come too slowly, and that
// is when the lower bands should wait.
return max(sat, r.sloPressure(model)), true
}
func toInfos(eps []config.Endpoint) []endpoint.Info {
out := make([]endpoint.Info, 0, len(eps))
for _, e := range eps {
out = append(out, endpoint.Info{
ID: e.ID,
URL: strings.TrimRight(e.URL, "/"),
APIKey: e.APIKey,
Models: e.Models,
Engine: e.Engine,
Pool: e.Pool,
Weight: e.Weight,
Priority: e.Priority,
Workflow: e.Workflow,
})
}
return out
}
// Start begins scraping endpoint metrics and dispatching admissions.
// It returns immediately.
func (r *Router) Start(ctx context.Context) {
r.poller.Sync(ctx)
if r.lora != nil {
go r.lora.run(ctx)
}
if r.adm != nil {
r.adm.Start()
}
if r.recorder != nil {
r.recorder.Start(ctx)
}
if r.acct != nil && r.store != nil {
// Budgets are counted in memory; the store is what makes them survive a
// restart. A failure here is not fatal — refusing to start because last
// month's spend could not be read would be worse than starting with
// counters that are low.
if err := r.acct.RestoreBudgets(ctx, r.store, storepkg.PeriodMonth); err != nil {
r.log.Warn("could not restore budgets; they start from zero this period", "err", err)
}
}
if r.threads != nil {
go r.pruneThreads(ctx)
}
if r.store != nil && r.usageRetention > 0 {
go r.pruneUsage(ctx, r.usageRetention)
}
if r.store != nil {
go r.runBatches(ctx)
go r.pruneRequestBodies(ctx)
go r.refreshTenants(ctx)
go r.purgeSessions(ctx)
}
}
// purgeSessions deletes sessions and key claims that can no longer be used,
// hourly and once at startup: refused already, but a table that only grows
// is its own problem, and a session row is a credential's hash kept for
// nothing.
func (r *Router) purgeSessions(ctx context.Context) {
purge := func() {
now := time.Now()
if n, err := r.store.PurgeSessions(ctx, now); err != nil {
r.log.Warn("could not remove expired sessions", "err", err)
} else if n > 0 {
r.log.Info("removed expired and idle sessions", "sessions", n)
}
if n, err := r.store.PurgeClaims(ctx, now); err != nil {
r.log.Warn("could not remove used and expired key claims", "err", err)
} else if n > 0 {
r.log.Info("removed used and expired key claims", "claims", n)
}
}
purge()
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
purge()
}
}
}
// refreshTenants keeps the tenant names the request series are labelled with,
// and the budget gauges, a minute fresh.
func (r *Router) refreshTenants(ctx context.Context) {
refresh := func() {
if err := r.m.tenant.refresh(ctx, r.store, time.Now()); err != nil && ctx.Err() == nil {
r.log.Warn("could not refresh tenant names and budget gauges", "err", err)
}
}
refresh()
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
refresh()
}
}
}
// pruneUsage deletes request records older than the retention, hourly and
// once at startup, as pruneThreads does. The five-minute sums stay, so no
// total or chart moves; what goes is the trail of each old request.
func (r *Router) pruneUsage(ctx context.Context, keep time.Duration) {
prune := func() {
n, err := r.store.PruneUsage(ctx, time.Now().Add(-keep))
if err != nil {
r.log.Warn("could not prune old request records", "err", err)
return
}
if n > 0 {
r.log.Info("removed request records past their retention", "rows", n, "retention", keep)
}
}
prune()
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
prune()
}
}
}
// pruneThreads enforces each project's retention window.
//
// Without something running this, "we keep conversations for 24 hours" is a
// comment rather than a promise — the rows would simply accumulate, and the
// number a project chose would describe nothing.
//
// Hourly, and once at startup. A gateway that has been down for a week should
// not wait another hour before honouring a window that expired during it, and
// an hour is fine granularity for a limit expressed in hours.
func (r *Router) pruneThreads(ctx context.Context) {
prune := func() {
n, err := r.threads.PruneThreads(ctx, time.Now())
if err != nil {
r.log.Warn("could not prune expired conversations", "err", err)
return
}
if n > 0 {
r.log.Info("removed conversations past their retention", "threads", n)
}
m, err := r.threads.PruneMemory(ctx, time.Now())
if err != nil {
r.log.Warn("could not prune expired memory notes", "err", err)
return
}
if m > 0 {
r.log.Info("removed memory notes past their retention", "notes", m)
}
}
prune()
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
prune()
}
}
}
// Stop halts scraping and admission.
func (r *Router) Stop() {
r.poller.Stop()
if r.adm != nil {
r.adm.Close()
}
// Drain before closing the database, or the last batch has nowhere to go.
if r.recorder != nil {
r.recorder.Stop()
}
if r.store != nil {
_ = r.store.Close()
}
if r.ownsThreads {
_ = r.threads.Close()
}
}
// SetLeader tells /leader how to know whether this process leads.
func (r *Router) SetLeader(f func() bool) { r.isLeader.Store(&f) }
// Registry exposes the endpoint registry, for tests and admin endpoints.
func (r *Router) Registry() *endpoint.Registry { return r.reg }
// SetEndpoints replaces the endpoint list and resynchronizes metric scraping.
//
// This is how the control plane feeds the router from phase 3 onwards, taking
// over from the static configuration list (plan §6.4). Endpoints that survive
// the update keep their live state — inflight counts, breaker state, drain rate
// — so a replica that was already serving does not start from cold just
// because another one moved.
// modelMap returns the name→model map in force.
//
// Never nil: a router built before any catalog arrived resolves nothing, which
// is the same answer it gave for an unknown name before.
func (r *Router) modelMap() map[string]catalog.Model {
if m := r.models.Load(); m != nil {
return *m
}
return nil
}
// SetCatalog replaces the models this router knows about.
//
// The catalog's counterpart to SetEndpoints, and pushed for the same reason:
// the control plane owns what should exist, the router needs to know, and
// neither has to be in the other's process for that to work.
func (r *Router) SetCatalog(c *catalog.Catalog) { r.storeModels(c.Models()) }
// storeModels makes models what names resolve to: each model by its name, its
// aliases and its adapters. One place for it, whether the models came from
// configuration at start or from the control plane since.
func (r *Router) storeModels(models []catalog.Model) {
m := map[string]catalog.Model{}
ads := map[string]adapterRef{}
for _, entry := range models {
m[entry.Name] = entry
for _, a := range entry.Aliases {
m[a] = entry
}
// An adapter resolves to the model it runs on, as an alias does, and
// is applied to the request on the way there.
for i, a := range entry.Adapters {
m[a.Name] = entry
ads[a.Name] = adapterRef{base: entry.Name, owner: a.Owner, index: i, scale: a.EffectiveScale(),
limit: entry.MaxLoadedAdapters, path: engines.RuntimeLoRAPath(a.Name, a.Source)}
}
}
r.models.Store(&m)
r.adapters.Store(&ads)
targets := map[string]string{}
for _, entry := range models {
if entry.Canary != nil {
if t, ok := m[entry.Canary.Model]; ok {
targets[t.Name] = entry.Name
}
}
}
r.canaryTargets.Store(&targets)
if r.m != nil {
r.m.token.setSLOs(models)
}
}
// adapterRef is where an adapter runs and how it is applied.
type adapterRef struct {
base string
// owner is who sees it (catalog.Adapter.Owner); empty is everyone.
owner string
index int
scale float64
// limit is the model's max_loaded_adapters: above zero, the adapter
// loads on demand, from path.
limit int
path string
}
// onDemand reports whether the adapter is loaded when asked for rather than
// at start.
func (a adapterRef) onDemand() bool { return a.limit > 0 }
// adapter finds one by the name a client asked for.
func (r *Router) adapter(name string) (adapterRef, bool) {
if p := r.adapters.Load(); p != nil {
a, ok := (*p)[name]
return a, ok
}
return adapterRef{}, false
}
// adapterHidden reports whether name is an adapter its owner keeps from this
// caller: one outside the part of the tree it belongs to, or one with no
// identity at all. Such a caller is told there is no such model.
func (r *Router) adapterHidden(p auth.Principal, authed bool, name string) bool {
ad, ok := r.adapter(name)
if !ok || ad.owner == "" {
return false
}
if !authed {
return true
}
scope, id, _ := strings.Cut(ad.owner, ":")
switch scope {
case "org":
return p.OrgID != id
case "team":
return p.TeamID != id
case "project":
return p.ProjectID != id
case "user":
return p.UserID != id
case "key":
return p.KeyID != id
}
return true
}
// LastUsed reports when each endpoint was last sent a request, satisfying
// control.UsageSource so the control plane can tell a busy replica from one
// holding its cards out of habit.
func (r *Router) LastUsed() map[string]time.Time { return r.reg.LastUsed() }
// ModelLoad sums what each model's replicas report: how many there are, and
// requests running and waiting at their engines. Keyed by the catalog name,
// which the control plane publishes first among each endpoint's names.
func (r *Router) ModelLoad() map[string]reconciler.Load {
out := map[string]reconciler.Load{}
for _, s := range r.reg.Snapshots("") {
if len(s.Models) == 0 {
continue
}
l := out[s.Models[0]]
l.Replicas++
l.Running += s.Stats.Running
l.Waiting += s.Stats.Waiting
out[s.Models[0]] = l
}
return out
}
func (r *Router) SetEndpoints(ctx context.Context, infos []endpoint.Info) {
// A replica that has gone takes its cache with it. Leaving its entries
// in the tree or the index would steer traffic towards something that is
// not there, and since nothing reuses a replica id the entries would never
// be corrected — only expire, minutes later, or never.
before := map[string]struct{}{}
for _, s := range r.reg.Snapshots("") {
before[s.ID] = struct{}{}
}
for _, i := range infos {
delete(before, i.ID)
}
for id := range before {
r.prefix.Forget(id)
r.kv.Forget(id)
}
if r.lora != nil {
ids := make(map[string]struct{}, len(infos))
for _, i := range infos {
ids[i.ID] = struct{}{}
}
r.lora.keep(ids)
}
r.reg.Replace(infos)
r.poller.Sync(ctx)
}
// --- model resolution ---
// resolve maps a client-supplied model name onto the catalog. Unknown names are
// passed through unchanged: an endpoint may serve a model the catalog does not
// list, and refusing it would be unhelpful.
func (r *Router) resolve(name string) (canonical string, m catalog.Model) {
if e, ok := r.modelMap()[name]; ok {
return e.Name, e
}
return name, catalog.Model{Name: name}
}
func (r *Router) profileFor(m catalog.Model, override string) *scheduler.Profile {
for _, name := range []string{override, m.RoutingProfile, r.cfg.DefaultProfile} {
if p, ok := r.profiles[name]; ok && name != "" {
return p
}
}
return r.profiles["chat"]
}
// --- dispatch ---
// attempt describes one upstream try, for logging and metrics.
type dispatchResult struct {
endpointID string
pool string
servedModel
outcome proxy.Outcome
err error
// guard is what the output filter decided, if there was one, and
// judged whether there was.
guard guard.Verdict
judged bool
// canaryOf is the model whose canary this request was split by, canary
// the candidate, and onCanary whether the candidate served it.
canaryOf, canary string
onCanary bool
// cached is an answer from the project's answer cache, which no model
// was asked for.
cached bool
}
type servedModel struct {
model string
// fallback is true when the served model is not the one that was requested.
fallback bool
}
// dispatch resolves the model, then tries endpoints until one works, the client
// has been written to, or the attempt budget runs out.
//
// The retry rule (plan §7.7) is strict: retry only while nothing has reached the
// client. Once bytes are out, a retry would splice two different responses
// together, which is worse than the error the client would otherwise see.
func (r *Router) dispatch(ctx context.Context, w http.ResponseWriter, req *parsedRequest) dispatchResult {
canonical, entry := r.resolve(req.Model)
// A canary takes its share before anything else, and falls back to the
// stable side, not to a fallback, when it has nothing ready.
// Not an adapter: the candidate's replicas do not run it under the
// stable model's name.
_, isAdapter := r.adapter(req.Model)
if cn := entry.Canary; cn != nil && !isAdapter {
onCanary := chooseVariant(req, cn)
target, _ := r.resolve(cn.Model)
if onCanary && len(r.reg.Snapshots(target)) == 0 {
onCanary = false
}
if _, te := r.resolve(target); onCanary && (req.noStore || req.inCluster) && te.IsExternal() {
onCanary = false
}
setVariantHeader(w.Header(), onCanary)
if onCanary {
// The engine is asked for the model by the name it serves.
creq := *req
creq.UpstreamBody = withModel(req.UpstreamBody, target)
res := r.dispatchOne(ctx, w, &creq, target, entry)
res.model, res.canaryOf, res.canary, res.onCanary = target, canonical, target, true
if res.err == nil || res.outcome.Wrote || errors.Is(res.err, proxy.ErrClientGone) ||
!errors.Is(res.err, errNoCapacity) {
return res
}
setVariantHeader(w.Header(), false)
}
res := r.dispatchStable(ctx, w, req, canonical, entry)
res.canaryOf, res.canary = canonical, target
return res
}
return r.dispatchStable(ctx, w, req, canonical, entry)
}
// dispatchStable serves a model as asked, then its fallbacks.
func (r *Router) dispatchStable(ctx context.Context, w http.ResponseWriter, req *parsedRequest, canonical string, entry catalog.Model) dispatchResult {
// The requested model first, then its fallbacks. A fallback is a different
// model, so the client is told about it via x-llmgw-served-model.
chain := []string{canonical}
p, authed := auth.FromContext(ctx)
for _, fb := range entry.Fallbacks {
// A fallback is another model, and a key allowed only the one it
// asked for is not served another because that one was full.
if authed && !r.permits(p, fb) || r.isGuardModel(fb) {
continue
}
// Nor sent outside the cluster under a policy that keeps nothing.
if _, fe := r.resolve(fb); (req.noStore || req.inCluster) && fe.IsExternal() {
continue
}
chain = append(chain, fb)
}
var last dispatchResult
for i, model := range chain {
if i > 0 {
// Before dispatching, because dispatchOne writes the response and
// a header set after that is a header nobody receives. It was set
// in finish until now, which is after the proxy has already called
// WriteHeader — so this has announced a fallback to nobody since it
// was written.
w.Header().Set("x-llmgw-served-model", model)
}
res := r.dispatchOne(ctx, w, req, model, entry)
res.model = model
res.fallback = i > 0
if res.err == nil || res.outcome.Wrote || errors.Is(res.err, proxy.ErrClientGone) {
return res
}
last = res
// Only a total absence of capacity justifies moving to another model;
// a bad request would fail identically there.
if !errors.Is(res.err, errNoCapacity) {
return res
}
}
return last
}
var errNoCapacity = errors.New("no endpoint available")
// errLoading means the model is being started and the wait ran out.
//
// Distinct from having no capacity because the right client behaviour differs:
// this one succeeds on a retry, and the other does not.
var errLoading = errors.New("model loading")
func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *parsedRequest, model string, entry catalog.Model) dispatchResult {
candidates := r.reg.Snapshots(model)
if visited := withoutVisited(candidates, req.Header); len(visited) < len(candidates) {
if len(visited) == 0 {
return dispatchResult{err: fmt.Errorf("%w: %s is served by a federated cluster this request has already passed through",
errNoCapacity, model)}
}
candidates = visited
}
if len(candidates) == 0 {
// Nothing is serving it. That may be because nothing ever will, or
// because it loads when asked and nobody has asked yet — and the second
// is not an error, it is the feature working.
if r.activate(ctx, model) {
candidates = r.reg.Snapshots(model)
}
}
if len(candidates) == 0 {
if why, no := r.whyUnservable(model); no && r.activator != nil {
// Not "loading; retry": it will not load, and a client told to
// retry keeps retrying something that never arrives.
return dispatchResult{err: fmt.Errorf("%w: %s", errNoCapacity, why)}
}
if r.activator != nil && r.activator.Want(model) {
// Asked for, still loading. Saying "no endpoint serves this" would
// be a lie, and a client told to retry will succeed where one told
// the model does not exist gives up.
return dispatchResult{err: fmt.Errorf("%w: %s", errLoading,
"the model is loading; retry shortly")}
}
return dispatchResult{err: fmt.Errorf("%w: %s", errNoCapacity, noEndpointFor(model, slices.DeleteFunc(r.servedModels(), r.hiddenModel)))}
}
profile := r.profileFor(entry, req.ProfileOverride)
sctx := &scheduler.Ctx{
Now: time.Now(),
Req: scheduler.Request{
Model: model,
SessionID: req.SessionID,
PromptPrefix: req.PromptPrefix,
PromptTokens: req.PromptTokens,
MaxTokens: req.MaxTokens,
},
}
// An adapter asked for by name runs on its model's replicas — not on a
// fallback to another model, which does not have it.
ad, isAdapter := r.adapter(req.Model)
isAdapter = isAdapter && ad.base == model
onDemand := isAdapter && ad.onDemand() && r.lora != nil
if onDemand {
sctx.Req.Adapter = req.Model
r.lora.syncUnknown(ctx, candidates, model)
}
// Only where a profile publishes events, and only for a profile with a
// prefix scorer to read the answer: the batch profile does not care where
// a prefix is cached, and should not pay a round trip to find out.
if entry.KVIndex() && profile.Uses("prefix") {
_, kspan := tracer().Start(ctx, "kv index")
sctx.Req.KV = r.kvQuery(ctx, req, model, candidates)
if sctx.Req.KV.Fallback != "" {
kspan.SetAttributes(attribute.String("llmgw.kv_fallback", string(sctx.Req.KV.Fallback)))
}
kspan.End()
}
attempts := max(r.cfg.Retries+1, 1)
var last dispatchResult
for range attempts {
if len(candidates) == 0 {
break
}
if onDemand {
sctx.Adapters = r.lora.view(candidates, req.Model, ad.limit)
}
_, sspan := tracer().Start(ctx, "schedule", trace.WithAttributes(
attribute.String("llmgw.model", model), attribute.Int("llmgw.candidates", len(candidates))))
dec, err := profile.Schedule(sctx, candidates)
if err == nil {
sspan.SetAttributes(attribute.String("llmgw.endpoint", dec.Endpoint.ID))
}
endSpan(sspan, err)
if err != nil {
last = dispatchResult{err: fmt.Errorf("%w: %v", errNoCapacity, err)}
break
}
release, granted := r.reg.Acquire(dec.Endpoint.ID)
if !granted {
// The breaker opened between scoring and dispatch; drop it and
// score again rather than failing the request.
candidates = without(candidates, dec.Endpoint.ID)
continue
}
// The replica loads the adapter first if it does not hold it.
// Counted against its inflight while it does, so the requests
// arriving meanwhile see it busy.
releaseAdapter := func() {}
if onDemand {
how, rel, lerr := r.lora.acquire(ctx, dec.Endpoint.Info, model, req.Model, ad.path, ad.limit)
if lerr != nil {
release(true)
if ctx.Err() != nil {
return dispatchResult{endpointID: dec.Endpoint.ID, err: proxy.ErrClientGone}
}
res := dispatchResult{endpointID: dec.Endpoint.ID, pool: dec.Endpoint.Pool,
err: fmt.Errorf("adapter %s on %s: %w", req.Model, dec.Endpoint.ID, lerr)}
if errors.Is(lerr, errAdapterLoad) {
// Refused for what it is, it would be refused everywhere.
return res
}
r.log.Warn("a replica could not load an adapter, trying another",
"endpoint", dec.Endpoint.ID, "adapter", req.Model, "err", lerr)
last = res
candidates = without(candidates, dec.Endpoint.ID)
continue
}
releaseAdapter = rel
r.m.adapterAffinity.WithLabelValues(r.metricModel(model), how).Inc()
}
uctx, uspan := tracer().Start(ctx, "upstream", trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(attribute.String("llmgw.endpoint", dec.Endpoint.ID),
attribute.String("url.full", dec.Endpoint.URL+req.Path)))
body := req.UpstreamBody
if dec.Endpoint.UpstreamModel != "" && req.ContentType == "" {
body = withModel(body, dec.Endpoint.UpstreamModel)
}
// llama-server is asked for the model with the adapter applied to
// this request; vLLM serves each adapter as a model of its own name,
// which is what the client asked for already.
if isAdapter && !onDemand && dec.Endpoint.Engine != "vllm" {
body = withAdapter(body, model, ad.index, ad.scale)
}
// Passages share cached blocks only with requests that could have
// been given the same ones: a prefix found warm would otherwise say
// what another tenant asked about, by how fast its answer started.
// vLLM keys its prefix cache by this salt; other engines are not
// sent a field they may refuse.
if req.kbSalt != "" && dec.Endpoint.Engine == "vllm" && req.ContentType == "" {
body = withCacheSalt(body, req.kbSalt)
}
// What computes the answer, said before the proxy writes the headers.
// A model can have replicas on cards and on CPUs at once, and the two
// differ tenfold in speed: a reply that came slowly is otherwise
// indistinguishable from a model that is slow everywhere. Set again on
// a retry, which only happens before anything was written.
if hw := hardwareOf(dec.Endpoint.Pool); hw != "" {
w.Header().Set(hardwareHeader, hw)
} else {
w.Header().Del(hardwareHeader)
}
preq := proxy.Request{
URL: dec.Endpoint.URL + req.Path,
APIKey: dec.Endpoint.APIKey,
Body: body,
Header: withTraceparent(uctx, req.Header),
Stream: req.Stream,
SuppressUsageChunk: req.SuppressUsageChunk,
CaptureReply: req.captureBytes(),
ContentType: req.ContentType,
}
if onDemand {
// vLLM's answer for a model it does not have: the adapter was
// unloaded behind this router's back, by another router or a
// restart. Not relayed; loaded again and tried again.
preq.RetryStatus = http.StatusNotFound
}
if dec.Endpoint.Peer != "" {
preq.Extra = r.peerHeaders(ctx, dec.Endpoint, req.Header)
}
if req.Stream {
gaps := r.m.token.gap.WithLabelValues(r.metricModel(model))
preq.OnEvent = func(d time.Duration) { gaps.Observe(d.Seconds()) }
}
// A filter per attempt, for the model this attempt is served by.
filter := r.outputFilter(uctx, req, model, &preq)
var out proxy.Outcome
var perr error
if dec.Endpoint.Engine == endpoint.EngineComfyUI {
out, perr = r.generateImages(uctx, w, req, dec.Endpoint.Info, body)
} else {
out, perr = r.px.Do(uctx, w, preq)
}
releaseAdapter()
uspan.SetAttributes(attribute.Int("http.response.status_code", out.Status))
endSpan(uspan, perr)
adapterGone := onDemand && !out.Wrote && out.Status == http.StatusNotFound
if adapterGone {
r.lora.forget(dec.Endpoint.ID, req.Model)
}
// A retryable failure is the endpoint's fault; a client disconnect is
// not, and nor is an adapter it was not known to have lost.
release(perr == nil || errors.Is(perr, proxy.ErrClientGone) || adapterGone)
res := dispatchResult{endpointID: dec.Endpoint.ID, pool: dec.Endpoint.Pool, outcome: out, err: perr}
if filter != nil {
res.guard = filter.Verdict()
res.judged = true
}
r.m.observeScores(dec)
// Recorded after the decision, not during it: the scorer proposes and
// the picker disposes, so recording the proposal would fill the tree
// with replicas that were never sent anything — the one thing that
// would make the estimate worse than no estimate at all.
if r.prefix != nil {
if !req.noStore {
r.prefix.Observe(req.PromptPrefix, dec.Endpoint.ID)
}
}
if perr == nil || out.Wrote || !out.Retryable {
return res
}
last = res
if adapterGone {
// The replica is fine; the next try may load the adapter on it
// again.
r.log.Info("a replica no longer held an adapter; loading it again",
"endpoint", dec.Endpoint.ID, "adapter", req.Model)
continue
}
r.log.Warn("upstream attempt failed, trying another endpoint",
"endpoint", dec.Endpoint.ID, "model", model, "err", perr)
candidates = without(candidates, dec.Endpoint.ID)
}
if last.err == nil {
last.err = fmt.Errorf("%w: every endpoint for %q failed", errNoCapacity, model)
} else if !errors.Is(last.err, errNoCapacity) {
last.err = fmt.Errorf("%w: %v", errNoCapacity, last.err)
}
return last
}
// hardwareHeader says what the replica that answered computes on: "cpu", or
// the GPU class, such as "6000-24g".
const hardwareHeader = "x-llmgw-hardware"
// hardwareOf reads it from a pool, which placement names fabric/class —
// "none/cpu", "none/6000-24g", "ib-a/h100-80g". Empty for an endpoint
// configured by hand, which has no pool and says nothing about its hardware.
func hardwareOf(pool string) string {
i := strings.LastIndexByte(pool, '/')
if i < 0 || i == len(pool)-1 {
return ""
}
return pool[i+1:]
}
func without(eps []endpoint.Snapshot, id string) []endpoint.Snapshot {
return slices.DeleteFunc(slices.Clone(eps), func(e endpoint.Snapshot) bool { return e.ID == id })
}
// personalLimits turns the personal block into limits, nil when it sets none.
func personalLimits(p config.Personal) *storepkg.Limits {
var l storepkg.Limits
if p.RPM > 0 {
v := p.RPM
l.RPM = &v
}
if p.TPM > 0 {
v := p.TPM
l.TPM = &v
}
if p.Budget > 0 {
v := int64(math.Round(p.Budget * 1e6))
l.Budget = &v
l.BudgetPeriod = storepkg.PeriodMonth
if p.Period == "day" {
l.BudgetPeriod = storepkg.PeriodDay
}
}
if l.RPM == nil && l.TPM == nil && l.Budget == nil {
return nil
}
return &l
}
// loopbackOnly reports whether a listen address is reachable from this
// machine alone. An empty host (":8080") is every interface.
func loopbackOnly(listen string) bool {
host, _, err := net.SplitHostPort(listen)
if err != nil {
return false
}
if host == "localhost" {
return true
}
a, err := netip.ParseAddr(host)
return err == nil && a.IsLoopback()
}