Files
llmgw/internal/config/config.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

1183 lines
49 KiB
Go

// Package config loads llmgw's YAML configuration.
//
// Phase 1 only needs the router's half of it: the endpoint list is static here
// and will come from the control plane in phase 3 (plan §6.4). The shape is
// already the one the plan describes, so that later change stays additive.
package config
import (
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/knadh/koanf/parsers/yaml"
env "github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/v2"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Server is the whole server configuration.
type Server struct {
// Listen is the client-facing OpenAI API address.
Listen string `koanf:"listen"`
// AdminListen serves the dashboard, the management API, health and pprof.
//
// Loopback by default, and it should stay there: pprof can dump the process
// heap, and API keys are in that heap in plaintext. Every comment beside
// this setting has always said not to expose it, while the default bound
// every interface — a default that contradicts its own documentation is a
// footgun, so the default now matches the advice.
AdminListen string `koanf:"admin_listen"`
// MetricsListen serves Prometheus metrics and nothing else.
//
// Separate from AdminListen because the reason that one is loopback is
// pprof, not metrics — and metrics are the one thing on it that something
// else genuinely needs to reach. Sharing a listener meant a fleet could
// either be monitored or keep its heap private, which is not a choice
// anybody should have to make.
//
// Empty means off, and metrics remain available on AdminListen either way,
// so nothing that scrapes it today has to change.
MetricsListen string `koanf:"metrics_listen"`
// AgentListen is where node agents connect. Separate from the client port
// so the fleet-facing surface can be firewalled independently.
AgentListen string `koanf:"agent_listen"`
// Auth turns on API keys on the client-facing port.
Auth Auth `koanf:"auth"`
// Threads keeps conversations and agent memory without API keys, for a
// gateway on one trusted machine. With auth on they live in the auth store,
// per project, and this block is ignored.
Threads Threads `koanf:"threads"`
// Landing configures the page served at "/".
Landing Landing `koanf:"landing"`
// AgentTLS authenticates the fleet-facing port. It deliberately does not
// cover the client-facing one: that surface is fronted by a publicly
// trusted certificate, while this one is mutual and private.
AgentTLS tlsconf.Config `koanf:"agent_tls"`
// MaxConnections bounds the client port's open connections; past it new
// ones wait in the kernel's backlog. 0 is no bound.
MaxConnections int `koanf:"max_connections"`
// AdminTokenFile holds the token the admin API's /api and /debug paths
// demand. Empty is admin-token in the systemd unit's state directory
// (/var/lib/llmgw), or secrets/admin-token beside this file for a server
// run by hand. Made, 0600, when the server starts and it does not exist;
// `sudo llmgw ctl` and the console read it from there.
AdminTokenFile string `koanf:"admin_token_file"`
// AdminRemote allows admin_listen on an address other than loopback. The
// token still guards the API, but it then crosses the network in clear.
AdminRemote bool `koanf:"admin_remote"`
// DebugPprof serves Go's profiler on the admin listener, behind the token.
// Off by default: a heap profile holds the API keys requests presented.
DebugPprof bool `koanf:"debug_pprof"`
// AgentInsecure lets a control plane run its agent port without
// agent_tls: plaintext, and any machine that reaches it may register as
// any node — be given replicas, and so be sent users' prompts — or, in
// the other direction, a machine in the path may tell every agent what
// to run. Refused unless said here, and then the port listens on
// loopback unless agent_listen names an address. For one machine, a
// test, or a network nothing untrusted reaches.
AgentInsecure bool `koanf:"agent_insecure"`
// Monitoring is where the dashboard reads its history from.
Monitoring Monitoring `koanf:"monitoring"`
// Tracing sends traces to an OpenTelemetry collector: each inference
// request, and on a control plane each placement, replica start and
// model job. Off unless an endpoint is set here or in
// OTEL_EXPORTER_OTLP_ENDPOINT.
Tracing obs.Tracing `koanf:"tracing"`
// Mirror points the control plane at a model mirror it can administer.
// Empty means there is none, and /admin/mirror stays unmounted.
Mirror Mirror `koanf:"mirror"`
// Offline is a cluster that reaches nothing outside itself: its nodes
// install engines, and upgrades fetch releases, from the mirror, out of
// a bundle carried in (llmgw bundle create, llmgw bundle import).
Offline Offline `koanf:"offline"`
// Engines are the engines every node installs, and at which version. The
// agents install them themselves — the right build for their hardware,
// checked against its publisher's checksum and seen to run — so a node is
// never trusted to already have what somebody once put there.
//
// Unset keeps DefaultEngines. A version replaces the default; an empty
// one leaves that engine to be installed by hand.
Engines map[string]string `koanf:"engines"`
// EngineOverrides give some nodes different engines from the rest: a
// vLLM built for CUDA 12 on the Volta cards CUDA 13 dropped, or none at
// all on nodes meant only for llama.cpp. Each picks nodes and lays its
// engines over the cluster's, in order, later ones last; an empty version
// removes that engine from the nodes it picks.
EngineOverrides []EngineOverride `koanf:"engine_overrides"`
// BackupDir is where `llmgw server backup` writes, and where the newest
// archive's age is read for llmgw_backup_last_success_timestamp_seconds.
// Empty means /var/backups/llmgw-server.
BackupDir string `koanf:"backup_dir"`
// Guard is the library of guardrail policies: what requests and answers
// are checked for, and what is done. Applied to every request through
// guard.default, and to parts of the tenancy tree by attaching them
// (llmgw ctl guard attach). Empty checks nothing.
Guard guard.Config `koanf:"guard"`
// Secrets is where secrets kept outside files are read from, and the key
// the store's own secrets are sealed under.
Secrets Secrets `koanf:"secrets"`
// Notify sends what needs a person — a model parked, a node lost, a
// budget spent, new weights published — to webhooks, signed, without a
// Prometheus and Alertmanager in between. Empty sends nothing.
Notify notify.Config `koanf:"notify"`
// Mail is where the gateway's few emails go out from: a link to set a
// forgotten password. Unset, none is sent and people ask an operator.
Mail mail.Config `koanf:"mail"`
// Serving stops and starts the whole cluster's service (llmgw stop, llmgw
// start).
Serving ServingSettings `koanf:"serving"`
// Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"`
// Agents are the nodes' agent settings: agent.yaml's keys but those an
// agent needs to start, for every node (default) and for one
// (nodes.<id>). Sent to the agents as they change; an agent whose
// settings changed starts again on them, its engines serving
// throughout.
Agents AgentSettings `koanf:"agents"`
// FirewallNetworks are networks, besides those each machine reaches the
// cluster on, that llmgw's internal ports answer: a monitoring host on
// another subnet, nodes behind a router. Written into every machine's
// firewall by `server init`, `node add` and `server add`.
FirewallNetworks []string `koanf:"firewall_networks"`
// BackupRecipients encrypt every archive `llmgw server backup` writes, to
// whoever holds one of their keys: age recipients (age1…) or SSH public
// keys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds
// the certificate authority's key and every secret in the store, and is
// copied off the machine; encrypted, the copy is safe wherever it lands.
// Empty writes archives unencrypted, readable by root only.
BackupRecipients []string `koanf:"backup_recipients"`
// BackupCopyTo is where each archive is copied once written, so it
// survives this machine: a directory (a mounted share, another disk) or
// ssh://user@host[:port]/path, reached with BackupCopyKey and checked
// against /etc/llmgw/backup_known_hosts, which learns a host the first
// time. The same number of archives is kept there as here. Refused
// without backup_recipients: an archive leaves the machine encrypted.
BackupCopyTo []string `koanf:"backup_copy_to"`
// BackupCopyKey is the SSH private key for ssh:// copies.
BackupCopyKey string `koanf:"backup_copy_key"`
// Releases is where `llmgw ctl upgrade` fetches release binaries from,
// for this machine and — through their agents — every node. Empty means
// the project's own releases.
Releases string `koanf:"releases"`
Router Router `koanf:"router"`
Control Control `koanf:"control"`
Models []catalog.Model `koanf:"models"`
// Facility is the power caps of racks, PDUs and other domains, and how
// a replica's draw is estimated against them. Where a node is comes from
// its llmgw.io/site, room, row, rack and pdu labels; holding a domain for
// maintenance is `llmgw ctl facility hold`, not configuration.
Facility Facility `koanf:"facility"`
// Federation is how this cluster takes part in federation with other
// llmgw clusters. The peers themselves are added at runtime, with
// `llmgw ctl federation add`, and kept in the store.
Federation Federation `koanf:"federation"`
// Endpoints is the phase-1 static replica list. It is only consulted when
// no model declares a profile, which is what makes a router-only smoke test
// against sim-engines possible without a control plane.
Endpoints []Endpoint `koanf:"endpoints"`
}
// Facility configures facility-aware placement: what each rack, PDU or other
// domain may draw, and the figures a replica's draw is estimated from when
// its profile declares none (power_watts).
//
// Nothing here is needed for placement to know where nodes are or to spread
// replicas across racks; that is the nodes' labels alone. A domain with no
// budget is not capped.
type Facility struct {
// Budgets are the domains with a power cap.
Budgets []FacilityBudget `koanf:"budgets"`
// Margin is the share of every cap kept free, 0.1 for ten percent. Zero
// keeps none, for caps already given derated.
Margin float64 `koanf:"margin"`
// GPUWatts is a card's draw when it does not report its power limit:
// an agent without nvidia-smi's power fields, or a node that has not
// sent a heartbeat yet. Zero counts such a card as drawing nothing.
GPUWatts float64 `koanf:"gpu_watts"`
// CPUCoreWatts is the draw of a core reserved by a replica with no GPU
// (cpu_cores). Zero counts such replicas as drawing nothing.
CPUCoreWatts float64 `koanf:"cpu_core_watts"`
// NodeWatts is what a node draws besides its cards and reserved cores —
// idle CPUs, memory, fans, disks — counted for every node in a capped
// domain, busy or not.
NodeWatts float64 `koanf:"node_watts"`
// MeasuredFresh is how old a node's power reading may be and still be
// counted against its domains. Zero takes two minutes.
MeasuredFresh time.Duration `koanf:"measured_fresh"`
}
// FacilityBudget is one domain's power cap.
type FacilityBudget struct {
// Domain is level:name, as the nodes' labels give it: rack:r12-07,
// pdu:r12-07-a, row:12.
Domain string `koanf:"domain"`
// CapWatts is what the domain may draw. For a dual-fed rack it is what
// one feed carries alone, derated as the facility derates it (usually
// 80%), since that is what the rack has to survive.
CapWatts float64 `koanf:"cap_watts"`
// OtherWatts is drawn in the domain by what llmgw does not place on:
// switches, storage, machines outside the cluster.
OtherWatts float64 `koanf:"other_watts"`
}
// Policy is the configuration as placement reads it.
func (f Facility) Policy() (facility.Policy, error) {
p := facility.Policy{
Margin: f.Margin, GPUWatts: f.GPUWatts, CPUCoreWatts: f.CPUCoreWatts,
NodeWatts: f.NodeWatts, MeasuredFresh: f.MeasuredFresh,
}
if f.Margin < 0 || f.Margin >= 1 {
return p, fmt.Errorf("facility.margin %v is a share of the cap, from 0 to below 1: 0.1 keeps ten percent free", f.Margin)
}
if f.GPUWatts < 0 || f.CPUCoreWatts < 0 || f.NodeWatts < 0 {
return p, fmt.Errorf("facility: gpu_watts, cpu_core_watts and node_watts cannot be negative")
}
for i, b := range f.Budgets {
d, err := facility.ParseDomain(b.Domain)
if err != nil {
return p, fmt.Errorf("facility.budgets[%d]: %w", i, err)
}
if b.CapWatts <= 0 {
return p, fmt.Errorf("facility.budgets[%d] (%s): cap_watts must be above zero", i, d)
}
if b.OtherWatts < 0 {
return p, fmt.Errorf("facility.budgets[%d] (%s): other_watts cannot be negative", i, d)
}
if p.Budgets == nil {
p.Budgets = map[facility.Domain]facility.Budget{}
}
if _, dup := p.Budgets[d]; dup {
return p, fmt.Errorf("facility.budgets[%d]: %s is given twice", i, d)
}
p.Budgets[d] = facility.Budget{CapWatts: b.CapWatts, OtherWatts: b.OtherWatts}
}
return p, nil
}
// Federation is this cluster's side of federation with other llmgw clusters.
type Federation struct {
// Name is this cluster as a peer reads it, in the federated_from tag
// sent with a request to a peer set to receive tags (`ctl federation add
// --send-tags`). Empty is this machine's host name.
Name string `koanf:"name"`
// Interval is how often each peer is asked whether it is ready, timed,
// and what it offers. A peer that does not answer is taken out of
// routing until it does. Zero is 10s.
Interval time.Duration `koanf:"interval"`
}
// Monitoring points the dashboard at a Prometheus holding this cluster's
// history.
//
// Read back rather than kept twice. llmgw publishes these metrics already; what
// a gauge cannot do is remember, and a ring buffer in this process would be a
// rival copy with its own retention, lost on every restart and disagreeing with
// the one an operator already has on screen.
type Monitoring struct {
// Prometheus is the server's address, for example
// http://monitoring.internal:9090. Empty leaves the charts off the
// dashboard rather than showing an error where one should be.
Prometheus string `koanf:"prometheus"`
// Window is how far back the charts look. Zero takes an hour.
Window time.Duration `koanf:"window"`
// Timeout bounds one query. It runs while somebody waits for a page, so a
// monitoring system that is slow today is skipped rather than waited for.
Timeout time.Duration `koanf:"timeout"`
}
// Mirror is a model mirror this control plane administers over its API.
//
// The mirror is a separate machine with a large disk, not part of the gateway,
// so what is configured here is an address and how to authenticate to it —
// never a directory. A control plane that could write to the mirror's disk
// directly would be a way to replace a model with another one that every node
// would then fetch without complaint.
type Mirror struct {
// API is the mirror's management address, scheme included, for example
// https://models.internal:8082. Empty means no mirror is configured.
//
// The management port, not the one nodes fetch bytes from. Those are two
// listeners on the mirror precisely so this one can be locked down without
// re-certifying every agent's fetcher.
API string `koanf:"api"`
// TLS authenticates to the mirror's management API, with the same CA the
// agents use. Empty leaves the connection in plain HTTP, which is only
// acceptable on a network where nothing untrusted can reach that port —
// the mirror says as much in its own log when it starts that way.
TLS tlsconf.Config `koanf:"tls"`
// Registry is the model registry the mirror page browses, empty for the
// public Hugging Face. Set it to an internal mirror of the registry where
// the control plane cannot reach the internet — or leave the whole mirror
// block out, which turns the explorer off rather than giving it a search
// box that can only time out.
Registry string `koanf:"registry"`
// Timeout bounds a management call. It does not bound a download: the
// mirror answers a fetch as soon as it has started one, and the bytes keep
// arriving long after this deadline has passed.
Timeout time.Duration `koanf:"timeout"`
// UpdateCheck is how often the registry is asked whether the files of
// the models served from it have changed there: a repository can publish
// new weights under the same name. Only told, never applied — moving a
// model to them is `llmgw ctl model update`. 0 never asks.
UpdateCheck time.Duration `koanf:"update_check"`
}
// Landing configures the public page at "/".
//
// It exists for one thing the gateway cannot know: who to ask for a key. A page
// that explains the API and then leaves the reader with no route to using it is
// where onboarding stops.
type Landing struct {
// Contact is where someone without a key should go — an address, a URL, a
// channel name. Rendered as a link when it is one, as text otherwise.
Contact string `koanf:"contact"`
// Notice is an optional line of your own, for whatever the reader needs to
// know that this page cannot guess.
Notice string `koanf:"notice"`
// Language is what the web portal speaks to someone who has not chosen
// a language with the switcher at the foot of every page and whose
// browser asks for none it speaks: "en" or "tr". Empty is English.
Language string `koanf:"language"`
// DocsSignIn serves the docs portal at /docs only to someone signed in.
// Off, the portal is public like this page: its client guide is for the
// person who has no account yet, and the operator documents describe
// llmgw in general — no address, model or key of this installation —
// which the project's repository publishes anyway. On, a visitor is sent
// to sign in, and without accounts the portal needs an API key.
DocsSignIn bool `koanf:"docs_sign_in"`
}
// Threads configures conversation and memory storage for a gateway without
// authentication.
//
// Everything stored is in one pool with no owner, readable by anyone who can
// reach the client port and knows an id. That is acceptable on one machine and
// wrong on a shared network, which is why it takes a setting rather than being
// the default.
type Threads struct {
// Store is the SQLite file. Empty means nothing is kept.
Store string `koanf:"store"`
// RetainHours is how long idle conversations and notes are kept. Required
// with Store: a store with no expiry keeps everything anyone typed, by
// omission.
RetainHours int `koanf:"retain_hours"`
}
// Auth configures client authentication (plan §7.9).
//
// Off by default, because a gateway on a trusted network with no keys issued
// still has to serve. Turning it on is what makes the client-facing port safe
// to expose.
type Auth struct {
Enabled bool `koanf:"enabled"`
// Store is the SQLite file holding keys, tenancy and usage.
Store string `koanf:"store"`
// CacheTTL bounds how long a revoked key keeps working, traded against a
// database read per key per TTL.
CacheTTL time.Duration `koanf:"cache_ttl"`
// NegativeTTL is how long a rejected key is remembered as rejected, so a
// client looping with a bad key cannot become a database read per request.
NegativeTTL time.Duration `koanf:"negative_ttl"`
// DefaultRPM and DefaultTPM apply to a key that no scope gives a limit to.
//
// Unlimited used to be the default, and a project created without limits is
// the easy path — the "default" project on this cluster still is one. The
// first sign of that is the bill or the outage. Zero keeps the old
// behaviour for anyone who wants it, but it should be a choice rather than
// an omission. A fallback, not a ceiling: an explicit limit at any scope
// wins, above or below.
DefaultRPM int `koanf:"default_rpm"`
DefaultTPM int `koanf:"default_tpm"`
// SignupOpen serves the signup form. Accounts it creates are pending until
// an operator approves them, so this is not a way in — but turning it off
// means accounts are made with `llmgw users create` instead.
SignupOpen bool `koanf:"signup_open"`
// OIDC is sign-in through an identity provider.
OIDC OIDC `koanf:"oidc"`
// SCIM is account provisioning by the identity provider.
SCIM SCIM `koanf:"scim"`
// MaxKeyTTL is how long any API key may live; a key asked for with no
// expiry is given this. Organizations, teams and projects can set
// shorter ones (llmgw keys ttl). Zero is no limit.
MaxKeyTTL time.Duration `koanf:"max_key_ttl"`
// Personal is what a person may do on their own, outside any project: make
// personal keys and use the chat page personally, within these limits.
Personal Personal `koanf:"personal"`
// Pricing is what each model costs, for budgets. A model absent from the
// table costs whatever DefaultPrice says, which is nothing unless set.
Pricing map[string]Price `koanf:"pricing"`
DefaultPrice Price `koanf:"default_price"`
// FlushInterval and BatchSize decide when usage records are written.
FlushInterval time.Duration `koanf:"flush_interval"`
BatchSize int `koanf:"batch_size"`
// BufferSize bounds the queue. Beyond it records are dropped rather than
// blocking a request: losing accounting is bad, turning a stalled database
// into an outage is worse.
BufferSize int `koanf:"buffer_size"`
// UsageRetention is how long the record of each request is kept: who sent
// it, which replica answered, how long it queued — what `ctl request`
// reads. Zero keeps it for ever. Totals, charts and budgets are read from
// five-minute sums kept beside the records, which are not pruned, so this
// bounds the table that grows by a row a request without changing any
// figure a page shows. At least a day when set.
UsageRetention time.Duration `koanf:"usage_retention"`
}
// Personal configures use that belongs to a person rather than a project.
//
// On by default: a person need not belong to anything to use the gateway. The
// limits are each person's own pool across all their personal keys and their
// personal chat, and apply only where nobody wrote a limit for that person.
type Personal struct {
// Disabled refuses personal keys and personal chat; people then use the
// gateway only through projects they are members of.
Disabled bool `koanf:"disabled"`
RPM int `koanf:"rpm"`
TPM int `koanf:"tpm"`
// Budget is in whole units per Period ("day" or "month").
Budget float64 `koanf:"budget"`
Period string `koanf:"period"`
}
// Price is a model's cost in micro-units per thousand tokens.
//
// What a unit means is the operator's choice — self-hosted models have no
// per-token price, so it may be GPU-seconds, a notional rate, or 1000
// everywhere, which turns a budget into a token quota (plan §7.9.4).
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// PerImage is the cost of one picture, for a model that makes them
// (/v1/images/generations), on top of its prompt's tokens.
PerImage int64 `koanf:"per_image"`
}
// Control configures the control plane.
type Control struct {
// Interval is how often desired and actual state are compared.
Interval time.Duration `koanf:"interval"`
// BaseBackoff and MaxBackoff bound how fast a failing model is retried.
BaseBackoff time.Duration `koanf:"base_backoff"`
MaxBackoff time.Duration `koanf:"max_backoff"`
// UnreachableAfter and LostAfter stage node expiry. They are deliberately
// far apart: a network blip must not tear down replicas that still serve,
// and rebuilding a multi-node replica costs tens of minutes of loading.
UnreachableAfter time.Duration `koanf:"unreachable_after"`
LostAfter time.Duration `koanf:"lost_after"`
// PortRange is the per-node range for engine HTTP and dist-init ports.
PortRange []int `koanf:"port_range"`
// MgmtInterface is the ethernet interface NCCL bootstraps over.
MgmtInterface string `koanf:"mgmt_interface"`
// Timezone is where models' scaling schedules are read, an IANA name
// like Europe/Istanbul; empty is the control plane's own.
Timezone string `koanf:"timezone"`
// EvalDir is where quality suites are read from by name, for `llmgw ctl
// eval MODEL --suite NAME`, a model's eval gate and the console. The
// suites the binary carries (arithmetic, json-format) are found when it
// has none of the name; a suite here of the same name replaces one.
EvalDir string `koanf:"eval_dir"`
// Enabled runs the control plane even when configuration declares no
// models.
//
// Without it, a deployment whose catalog lives entirely in the database
// cannot start: control plane mode is inferred from the config file, so an
// empty models block means "router only", the process refuses to start for
// want of anything to serve, and the models it would have loaded are never
// read. That is a cluster that cannot be managed at runtime because it is
// managed at runtime. AgentListen cannot carry this signal — it has a
// default, so every deployment would become a control plane.
Enabled bool `koanf:"enabled"`
// LeaseTTL is how long this control plane's claim to be the one placing
// work stands without being renewed.
//
// Only meaningful with a shared store, where a second control plane can
// see the claim. Long enough to survive a slow query or a brief network
// stall, short enough that a standby takes over before anyone has finished
// reading the alert. Zero takes the default.
LeaseTTL time.Duration `koanf:"lease_ttl"`
}
// Router holds routing behaviour.
type Router struct {
// ActivationWait is how long a request waits for an on-demand model to
// load before the client is told to come back. Zero takes the default.
//
// The one real decision in on-demand loading. Refusing outright makes the
// feature useless, since the first request for every model would fail;
// waiting without a bound turns a request into a job, and a client that
// budgeted thirty seconds ends up holding a connection for ten minutes.
ActivationWait time.Duration `koanf:"activation_wait"`
ScrapeInterval time.Duration `koanf:"scrape_interval"`
ScrapeTimeout time.Duration `koanf:"scrape_timeout"`
// StaleAfter is how long an endpoint may go unscraped before being scored
// down (plan §7.6).
StaleAfter time.Duration `koanf:"stale_after"`
// Retries is how many *additional* endpoints may be tried after a failure,
// and only while nothing has been written to the client (plan §7.7).
Retries int `koanf:"retries"`
// Cooldown is how long a failing endpoint leaves the candidate pool.
Cooldown time.Duration `koanf:"cooldown"`
// FailureThreshold is the consecutive failure count that opens a breaker.
// 1 turns the breaker into a pure cooldown.
FailureThreshold int `koanf:"failure_threshold"`
// DefaultProfile names the routing profile used when a model picks none.
DefaultProfile string `koanf:"default_profile"`
// Prefix turns on the approximate prefix tree (plan §7.5 tier 1). Off, the
// router uses session affinity alone, which is tier 0 and already most of
// the win for multi-turn traffic.
Prefix Prefix `koanf:"prefix"`
// KVIndex tunes the precise KV index (plan §7.5 tier 2). Which models
// use it is the catalog's to say, by kv_events on a vLLM profile; this
// is only how.
KVIndex KVIndex `koanf:"kv_index"`
// SessionHeader carries the session id for affinity (plan §7.5 tier 0).
SessionHeader string `koanf:"session_header"`
// InjectUsage asks engines for usage even when the client did not, so token
// accounting works regardless of client behaviour.
InjectUsage bool `koanf:"inject_usage"`
// TenantLabel is the level of the tree the per-tenant request series are
// labelled by — llmgw_tenant_requests_total and its siblings: "org",
// "team" or "project", or "none" for no such series. A label per project
// on a cluster of thousands is more series than Prometheus wants, and a
// team is what an alert is usually about; "team" is the default.
TenantLabel string `koanf:"tenant_label"`
// TagLabels are the request tags (metadata, x-llmgw-tags) counted in
// llmgw_tagged_requests_total and llmgw_tagged_tokens_total, by value.
// Only these: a tag is the client's to set, and every value of one is
// a series. The first 100 values of each are counted apart, the rest as
// "other". Empty counts none; the usage records keep every tag anyway.
TagLabels []string `koanf:"tag_labels"`
// MaxBodyBytes bounds the request body the router will read.
MaxBodyBytes int64 `koanf:"max_body_bytes"`
// Batch is the Files and Batch APIs (/v1/files, /v1/batches): a file of
// requests served at the lowest priority, its answers written to
// another. Needs the store.
Batch Batch `koanf:"batch"`
// Cache bounds the answers kept for the projects that reuse them
// (llmgw keys cache). Which projects do, and for how long, is theirs
// to set; this is only how much memory it may take.
Cache Cache `koanf:"cache"`
// TrustedProxies are the reverse proxies in front of the client port,
// as addresses or CIDR prefixes. Their X-Forwarded-For, -Proto and -Host
// are believed; anyone else's are not, since they decide the address
// written into the setup scripts and whose attempts are counted where.
TrustedProxies []string `koanf:"trusted_proxies"`
Timeouts Timeouts `koanf:"timeouts"`
Scoring Scoring `koanf:"scoring"`
Admission Admission `koanf:"admission"`
}
// Admission configures the flow control layer (plan §7.2).
type Admission struct {
// Enabled turns the layer on. Off, every request goes straight through,
// which is the v0.2.0 behaviour.
Enabled bool `koanf:"enabled"`
Bands []Band `koanf:"bands"`
DefaultBand string `koanf:"default_band"`
// BandHeader lets a client declare its band. Until authentication lands in
// v1.0.0 this is trusted, so the port must not be exposed to callers who
// should not be able to promote themselves.
BandHeader string `koanf:"band_header"`
QueueTTL time.Duration `koanf:"queue_ttl"`
QueueCapacityPerBand int `koanf:"queue_capacity_per_band"`
// QueueCapacityTotal bounds everything queued across bands. Reached, a
// request is not refused while a lower band has any queued: the newest of
// the lowest band is turned away instead, with a 503 and Retry-After.
// Zero takes half of all the bands' capacities together.
QueueCapacityTotal int `koanf:"queue_capacity_total"`
Interval time.Duration `koanf:"interval"`
}
// Prefix configures approximate prefix routing.
type Prefix struct {
Enabled bool `koanf:"enabled"`
// MaxNodes bounds the tree, MinMatch is the shortest match worth acting on
// in bytes, and TTL is how long an entry is believed — an engine evicts on
// its own schedule, so an old record may describe a cache that is gone.
MaxNodes int `koanf:"max_nodes"`
MinMatch int `koanf:"min_match"`
TTL time.Duration `koanf:"ttl"`
// MatchRatio is the share of the prompt that must match to be worth acting
// on. BalanceAbs and BalanceRel are the imbalance guard, and both must trip
// before affinity is abandoned.
MatchRatio float64 `koanf:"match_ratio"`
BalanceAbs float64 `koanf:"balance_abs"`
BalanceRel float64 `koanf:"balance_rel"`
}
// KVIndex tunes routing by the blocks each vLLM replica reports holding.
type KVIndex struct {
// TokenizeTimeout bounds the call that turns a request into token ids,
// made to one of the model's replicas before the request is placed. Past
// it the request is routed as tier 1 would. Zero takes 250ms.
TokenizeTimeout time.Duration `koanf:"tokenize_timeout"`
// MinBlocks is the fewest leading blocks a replica must hold to be
// preferred for them; fewer is coincidence — a chat template's opening
// tokens — rather than a prefix worth following. Zero takes 4, which is
// 64 tokens at vLLM's usual block size of 16.
MinBlocks int `koanf:"min_blocks"`
// MaxBlocks bounds how many of a prompt's blocks are hashed and looked
// up. Beyond it a longer match still helps, but the difference no longer
// decides anything. Zero takes 4096.
MaxBlocks int `koanf:"max_blocks"`
}
// Band is one priority class.
type Band struct {
Name string `koanf:"name"`
Priority int `koanf:"priority"`
SaturationCeiling float64 `koanf:"saturation_ceiling"`
}
// Timeouts bounds each phase of an upstream request.
type Timeouts struct {
Dial time.Duration `koanf:"dial"`
// ResponseHeader bounds the wait for headers on a streaming request. It is
// a liveness check there, because headers arrive before generation starts.
ResponseHeader time.Duration `koanf:"response_header"`
// Generation bounds a non-streaming request, where the engine sends no
// headers until the completion is done. Setting response_header alone used
// to cap both, so 60s read as a liveness guard and behaved as a limit of
// about a hundred tokens on a slow model.
Generation time.Duration `koanf:"generation"`
FirstToken time.Duration `koanf:"first_token"`
Idle time.Duration `koanf:"idle"`
Total time.Duration `koanf:"total"`
}
// Scoring holds the thresholds the scorers normalize against. These want to be
// measured with ctl bench (plan §10), not guessed.
type Scoring struct {
TargetWaiting float64 `koanf:"target_waiting"`
TargetInflight float64 `koanf:"target_inflight"`
KVCeiling float64 `koanf:"kv_ceiling"`
SessionLoadFactor float64 `koanf:"session_load_factor"`
}
// Endpoint is one engine replica. Static in phase 1.
type Endpoint struct {
ID string `koanf:"id"`
URL string `koanf:"url"`
// APIKey is sent to the engine as a bearer token, for an engine started
// with one.
APIKey string `koanf:"api_key"`
Models []string `koanf:"models"`
// Engine names the inference engine, which decides what its metrics are
// called. Empty means vLLM's names.
Engine string `koanf:"engine"`
// Workflow is the ComfyUI workflow for engine: comfyui, a built-in's
// name or its JSON; flux-schnell unless set.
Workflow string `koanf:"workflow"`
Pool string `koanf:"pool"`
Weight float64 `koanf:"weight"`
Priority int `koanf:"priority"`
}
// Secrets is where secrets are read from besides files, and how the
// store's own are sealed.
//
// Wherever a setting names a file holding a secret — the store's @address,
// api_key_file, client_secret_file, token_file, secret_file — it may name
// env:VARIABLE or vault:mount/path#field instead.
type Secrets struct {
// Vault is how vault: references and vault-transit: are reached.
Vault secrets.Vault `koanf:"vault"`
// MasterKey seals the secrets the store keeps for itself — what every
// engine's key is derived from — so a copy of the store, or of a backup,
// does not hold them: vault-transit:KEY seals with Vault's transit engine,
// which never lets the key out; otherwise a reference to 32 random bytes
// (openssl rand -hex 32), which then must be kept as safely as the store.
// Values from before it was set are sealed the first time they are read.
MasterKey string `koanf:"master_key"`
}
// Batch is how the Files and Batch APIs are bounded.
type Batch struct {
// MaxFileBytes bounds one uploaded file; 100 MB unless set.
MaxFileBytes int64 `koanf:"max_file_bytes"`
// MaxLines bounds the requests in one batch; 50 000 unless set.
MaxLines int `koanf:"max_lines"`
// Concurrency is how many of one batch's requests are in flight at
// once; 4 unless set. They queue in the lowest admission band, behind
// every interactive request.
Concurrency int `koanf:"concurrency"`
// RetainDays is how long a batch's answer and error files are kept;
// 30 unless set. Uploaded files are kept until deleted.
RetainDays int `koanf:"retain_days"`
}
// Cache is how much of this process's memory reused answers may take. The
// answers are held here and nowhere else: each gateway process keeps its
// own, and a restart begins them again.
type Cache struct {
// MaxBytes bounds every project's answers together; 128 MB unless set.
// Past it, the answers used longest ago go first.
MaxBytes int64 `koanf:"max_bytes"`
// MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB
// unless set.
MaxEntryBytes int64 `koanf:"max_entry_bytes"`
}
// Default returns the configuration used when nothing is specified.
func Default() Server {
return Server{
Listen: ":8080",
AdminListen: "127.0.0.1:9090",
AgentListen: ":7443",
MaxConnections: 10_000,
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
Firewall: FirewallSettings{Enabled: true},
Control: Control{
Interval: 5 * time.Second,
BaseBackoff: 10 * time.Second,
MaxBackoff: 10 * time.Minute,
UnreachableAfter: 15 * time.Second,
LostAfter: 60 * time.Second,
PortRange: []int{20000, 20999},
MgmtInterface: "eth0",
EvalDir: "/etc/llmgw/eval",
},
Router: Router{
ScrapeInterval: 2 * time.Second,
ScrapeTimeout: time.Second,
StaleAfter: 10 * time.Second,
Retries: 2,
Cooldown: 15 * time.Second,
FailureThreshold: 3,
DefaultProfile: "chat",
TenantLabel: "team",
SessionHeader: "x-session-id",
InjectUsage: true,
MaxBodyBytes: 32 << 20,
Timeouts: Timeouts{
Dial: 5 * time.Second,
ResponseHeader: 60 * time.Second,
FirstToken: 120 * time.Second,
Idle: 60 * time.Second,
Total: 30 * time.Minute,
},
Scoring: Scoring{
TargetWaiting: 4,
TargetInflight: 8,
KVCeiling: 0.9,
SessionLoadFactor: 1.25,
},
Admission: Admission{
Enabled: true,
BandHeader: "x-llmgw-band",
DefaultBand: "gold",
Bands: []Band{
{Name: "platinum", Priority: 400, SaturationCeiling: 1.00},
{Name: "gold", Priority: 300, SaturationCeiling: 0.85},
{Name: "silver", Priority: 200, SaturationCeiling: 0.60},
{Name: "bronze", Priority: 100, SaturationCeiling: 0.30},
},
QueueTTL: 30 * time.Second,
QueueCapacityPerBand: 2000,
Interval: 10 * time.Millisecond,
},
},
}
}
// Load reads configuration from path, overlaying it on the defaults, then
// applies LLMGW_-prefixed environment overrides.
//
// An empty path loads the defaults, which is what makes `llmgw server` with a
// couple of --endpoint flags usable for a smoke test.
func Load(path string) (Server, error) {
var b []byte
if path != "" {
if _, err := os.Stat(path); err != nil {
return Default(), fmt.Errorf("config file: %w", err)
}
var err error
if b, err = os.ReadFile(path); err != nil { //nolint:gosec // the configuration
return Default(), fmt.Errorf("config file: %w", err)
}
}
cfg, err := load(b)
if err != nil && path != "" && strings.HasPrefix(err.Error(), "parsing settings") {
return cfg, fmt.Errorf("parsing %s:%s", path, strings.TrimPrefix(err.Error(), "parsing settings:"))
}
return cfg, err
}
// load reads a YAML document of settings over the defaults.
func load(b []byte) (Server, error) {
cfg := Default()
k := koanf.New(".")
if len(b) > 0 {
if err := k.Load(bytesProvider(b), yaml.Parser()); err != nil {
return cfg, fmt.Errorf("parsing settings: %w", err)
}
}
// LLMGW_ROUTER__RETRIES=3 maps onto router.retries.
err := k.Load(env.Provider(".", env.Opt{
Prefix: "LLMGW_",
TransformFunc: func(key, value string) (string, any) {
key = strings.ToLower(strings.TrimPrefix(key, "LLMGW_"))
return strings.ReplaceAll(key, "__", "."), value
},
}), nil)
if err != nil {
return cfg, fmt.Errorf("environment overrides: %w", err)
}
if err := k.UnmarshalWithConf("", &cfg, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
return cfg, fmt.Errorf("decoding configuration: %w", err)
}
// So every secret reference in it, from here on, can be read.
if err := secrets.Configure(cfg.Secrets.Vault); err != nil {
return cfg, err
}
return cfg, cfg.Validate()
}
// Validate rejects configurations that would otherwise fail confusingly at
// request time.
func (s Server) Validate() error {
if err := s.Agents.Check(); err != nil {
return err
}
for i, o := range s.EngineOverrides {
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
return fmt.Errorf("engine_overrides[%d] picks no nodes: give it a gpu_class, nodes or labels", i)
}
if len(o.Engines) == 0 {
return fmt.Errorf("engine_overrides[%d] changes no engine: give it engines, with \"\" for none", i)
}
}
if s.Listen == "" {
return fmt.Errorf("listen is empty")
}
if _, err := s.Facility.Policy(); err != nil {
return err
}
if s.Control.Timezone != "" {
if _, err := time.LoadLocation(s.Control.Timezone); err != nil {
return fmt.Errorf("control.timezone %q: %w", s.Control.Timezone, err)
}
}
if _, err := ParseBackupRecipients(s.BackupRecipients); err != nil {
return err
}
for i, t := range s.BackupCopyTo {
switch {
case strings.HasPrefix(t, "ssh://"):
if s.BackupCopyKey == "" {
return fmt.Errorf("backup_copy_to[%d] is over ssh: give backup_copy_key", i)
}
case !strings.HasPrefix(t, "/"):
return fmt.Errorf("backup_copy_to[%d] %q: an absolute directory or ssh://user@host/path", i, t)
}
}
if len(s.BackupCopyTo) > 0 && len(s.BackupRecipients) == 0 {
return fmt.Errorf("backup_copy_to needs backup_recipients: an archive holds the certificate authority's key " +
"and every secret in the store, and leaves this machine only encrypted")
}
if err := s.Mail.Validate(); err != nil {
return err
}
if _, err := s.FirewallAllow(); err != nil {
return err
}
if err := s.Notify.Validate(); err != nil {
return err
}
if err := s.Guard.Validate(); err != nil {
return err
}
if err := s.Auth.OIDC.Validate(); err != nil {
return err
}
if err := s.AgentTLS.Validate(); err != nil {
return err
}
if err := s.Offline.Validate(); err != nil {
return err
}
if s.Auth.Enabled && s.Auth.Store == "" {
return fmt.Errorf("auth.store is required when auth is enabled")
}
switch s.Router.TenantLabel {
case "", "none", "org", "team", "project":
default:
return fmt.Errorf("router.tenant_label is %q: org, team, project or none", s.Router.TenantLabel)
}
if s.Auth.UsageRetention != 0 && s.Auth.UsageRetention < 24*time.Hour {
return fmt.Errorf("auth.usage_retention is %s: at least 24h, or 0 to keep every request", s.Auth.UsageRetention)
}
if s.Threads.Store != "" && s.Threads.RetainHours <= 0 {
return fmt.Errorf("threads.retain_hours must be positive when threads.store is set")
}
seen := make(map[string]struct{}, len(s.Endpoints))
for i, e := range s.Endpoints {
switch {
case e.ID == "":
return fmt.Errorf("endpoints[%d]: id is required", i)
case e.URL == "":
return fmt.Errorf("endpoint %q: url is required", e.ID)
case !strings.HasPrefix(e.URL, "http://") && !strings.HasPrefix(e.URL, "https://"):
return fmt.Errorf("endpoint %q: url must start with http:// or https://", e.ID)
case len(e.Models) == 0:
return fmt.Errorf("endpoint %q: at least one model is required", e.ID)
}
if _, dup := seen[e.ID]; dup {
return fmt.Errorf("endpoint id %q is used twice", e.ID)
}
seen[e.ID] = struct{}{}
}
// The catalog enforces the rest — aliases, fallbacks, parallelism
// arithmetic — so there is one place those rules live.
if _, err := s.Catalog(); err != nil {
return err
}
return nil
}
// Catalog builds the model catalog from the configuration.
func (s Server) Catalog() (*catalog.Catalog, error) {
return catalog.New(s.Models)
}
// ControlPlaneMode reports whether the server should run a control plane.
//
// A model with no profiles cannot be placed, so a configuration of bare names
// plus a static endpoint list is the router-only mode used for testing against
// sim-engines.
//
// With no static endpoints there is nothing a router-only process could serve,
// so a configuration naming neither endpoints nor models is a control plane
// with an empty catalog — which is what every new cluster is. It used to be
// refused as having nothing to serve, and under systemd that is a unit
// restarting every two seconds with nothing on 8080.
func (s Server) ControlPlaneMode() bool {
if s.Control.Enabled || len(s.Endpoints) == 0 {
return true
}
for _, m := range s.Models {
if len(m.Profiles) > 0 {
return true
}
}
return false
}
// DefaultEngines are the engine versions this release was checked against:
// every flag llmgw builds for them is one they take.
//
// vLLM is installed on every node with an NVIDIA GPU it can run on and
// skipped elsewhere. SGLang is not here: it is opt-in, by naming a version
// under engines.
var DefaultEngines = map[string]string{
"llamacpp": "b10991",
"vllm": "0.30.0",
}
// EngineOverride gives the nodes it picks different engines. Every selector
// set must match; at least one must be set.
type EngineOverride struct {
// GPUClass picks nodes with a GPU of this class ("v100-32g").
GPUClass string `koanf:"gpu_class"`
// Nodes picks nodes by id.
Nodes []string `koanf:"nodes"`
// Labels picks nodes carrying all of these.
Labels map[string]string `koanf:"labels"`
// Engines are laid over the cluster's: a version, or "" for none.
Engines map[string]string `koanf:"engines"`
}
// Picks reports whether the override applies to a node with this id, these
// GPU classes and these labels.
func (o EngineOverride) Picks(node string, classes []string, labels map[string]string) bool {
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
return false
}
if o.GPUClass != "" && !slices.Contains(classes, o.GPUClass) {
return false
}
if len(o.Nodes) > 0 && !slices.Contains(o.Nodes, node) {
return false
}
for k, v := range o.Labels {
if labels[k] != v {
return false
}
}
return true
}
// EnginesFor is what one node installs: WantedEngines with every override
// that picks it laid over, in order.
func (s Server) EnginesFor(node string, classes []string, labels map[string]string) map[string]string {
out := s.WantedEngines()
for _, o := range s.EngineOverrides {
if !o.Picks(node, classes, labels) {
continue
}
for k, v := range o.Engines {
if v == "" {
delete(out, k)
continue
}
out[k] = v
}
}
return out
}
// WantedEngines is DefaultEngines with the configuration laid over it.
func (s Server) WantedEngines() map[string]string {
out := map[string]string{}
for k, v := range DefaultEngines {
out[k] = v
}
for k, v := range s.Engines {
if v == "" {
delete(out, k)
continue
}
out[k] = v
}
return out
}
// Offline is a cluster with no way to the internet.
//
// Everything it would otherwise fetch from outside — llmgw's releases, the
// engine builds its nodes install, model files — is carried in as a bundle,
// made where there is internet (llmgw bundle create) and imported into the
// mirror here (llmgw bundle import). Nodes then fetch from the mirror, and
// check what they fetch as strictly as they would online: releases against
// the project's signing key, engines against the checksums the control plane
// holds from the bundle.
type Offline struct {
// Mirror is the mirror's file address, as nodes reach it:
// http://10.0.0.5:8081 — the port model files are served on, not the
// management API. Set, the cluster is offline: nodes install engines
// from it and never from their publishers, releases come from it unless
// releases names another place, and no registry is asked whether a
// model has newer weights. Python engines (vLLM, SGLang) come from PyPI,
// which no bundle carries; on an offline node they are installed by hand.
Mirror string `koanf:"mirror"`
// Checksums is the file of engine checksums this control plane trusts,
// kept by llmgw bundle import and llmgw bundle trust; every engine file
// a node fetches from the mirror is checked against it. Read again
// whenever it changes. Empty is /etc/llmgw/offline.sha256.
Checksums string `koanf:"checksums"`
}
// DefaultOfflineChecksums is where the engine checksums are kept when
// offline.checksums does not say.
const DefaultOfflineChecksums = "/etc/llmgw/offline.sha256"
// Enabled reports whether the cluster is offline.
func (o Offline) Enabled() bool { return o.Mirror != "" }
// ChecksumsFile is the engine checksums' file.
func (o Offline) ChecksumsFile() string {
if o.Checksums != "" {
return o.Checksums
}
return DefaultOfflineChecksums
}
// ReleasesBase is where an offline cluster's releases are on its mirror.
func (o Offline) ReleasesBase() string {
return strings.TrimSuffix(o.Mirror, "/") + "/_llmgw/releases"
}
// Validate refuses a mirror address nodes could not fetch from.
func (o Offline) Validate() error {
if o.Mirror == "" {
return nil
}
if !strings.HasPrefix(o.Mirror, "http://") && !strings.HasPrefix(o.Mirror, "https://") {
return fmt.Errorf("offline.mirror %q: give the mirror's file address, http://host:8081", o.Mirror)
}
if strings.HasSuffix(strings.TrimSuffix(o.Mirror, "/"), ":8082") {
return fmt.Errorf("offline.mirror %q is the mirror's management port: give the one nodes fetch files from, :8081", o.Mirror)
}
return nil
}
// ReleasesBase is where release binaries are fetched from: releases when
// set, the mirror's copy on an offline cluster, and empty — the caller's
// default, the project's own releases — otherwise.
func (s Server) ReleasesBase() string {
if s.Releases != "" {
return s.Releases
}
if s.Offline.Enabled() {
return s.Offline.ReleasesBase()
}
return ""
}