The GPU is a size-1 resource, so a single long job monopolises the box for its
whole duration and every interactive request queues behind it. Callers can now
declare intent with an X-LlamaSwap-Priority header and the serial scheduler
dispatches by score instead of by arrival.
- X-LlamaSwap-Priority: signed integer, 0 default, absent/unparseable means 0.
interactive/normal/batch aliases resolve to +100/0/-100. Values are not
clamped: the caller composes band and any per-user offset itself.
- serial dispatch score = priority + swap affinity + aging. Bands sit 100 apart
so a small caller offset orders work inside a band without crossing one;
aging is unbounded so low-priority work cannot starve.
- routing.scheduler.settings.serial.{agingDivisor,swapAffinityBonus}, defaulting
to 60s/point and +10. swapAffinityBonus is capped at 99 so it can never
promote a request into the next band.
- fifo adds the header to its per-model priority, so the header is not silently
ignored under that scheduler.
- /metrics exports per-band queue depth, oldest wait and dispatch counts, plus
counters for how often aging or swap affinity changed the pick. Each request
records its priority, band, score and queue wait in the activity log.
Note swapAffinityBonus defaults to 10, so equal-priority requests for the
already-loaded model now run before older requests that need a swap. Set it to
0 for the previous strict arrival order.
fixes #9
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WUyhZBgv8BBCC5MduX88gE
312 lines
8.9 KiB
Go
312 lines
8.9 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const DEFAULT_GROUP_ID = "(default)"
|
|
const (
|
|
LogToStdoutProxy = "proxy"
|
|
LogToStdoutUpstream = "upstream"
|
|
LogToStdoutBoth = "both"
|
|
LogToStdoutNone = "none"
|
|
)
|
|
|
|
type MacroEntry struct {
|
|
Name string
|
|
Value any
|
|
}
|
|
|
|
type MacroList []MacroEntry
|
|
|
|
// UnmarshalYAML implements custom YAML unmarshaling that preserves macro definition order
|
|
func (ml *MacroList) UnmarshalYAML(value *yaml.Node) error {
|
|
if value.Kind != yaml.MappingNode {
|
|
return fmt.Errorf("macros must be a mapping")
|
|
}
|
|
|
|
// yaml.Node.Content for a mapping contains alternating key/value nodes
|
|
entries := make([]MacroEntry, 0, len(value.Content)/2)
|
|
for i := 0; i < len(value.Content); i += 2 {
|
|
keyNode := value.Content[i]
|
|
valueNode := value.Content[i+1]
|
|
|
|
var name string
|
|
if err := keyNode.Decode(&name); err != nil {
|
|
return fmt.Errorf("failed to decode macro name: %w", err)
|
|
}
|
|
|
|
var val any
|
|
if err := valueNode.Decode(&val); err != nil {
|
|
return fmt.Errorf("failed to decode macro value for '%s': %w", name, err)
|
|
}
|
|
|
|
entries = append(entries, MacroEntry{Name: name, Value: val})
|
|
}
|
|
|
|
*ml = entries
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a macro value by name
|
|
func (ml MacroList) Get(name string) (any, bool) {
|
|
for _, entry := range ml {
|
|
if entry.Name == name {
|
|
return entry.Value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// ToMap converts MacroList to a map (for backward compatibility if needed)
|
|
func (ml MacroList) ToMap() map[string]any {
|
|
result := make(map[string]any, len(ml))
|
|
for _, entry := range ml {
|
|
result[entry.Name] = entry.Value
|
|
}
|
|
return result
|
|
}
|
|
|
|
type GroupConfig struct {
|
|
Swap bool `yaml:"swap"`
|
|
Exclusive bool `yaml:"exclusive"`
|
|
Persistent bool `yaml:"persistent"`
|
|
Members []string `yaml:"members"`
|
|
}
|
|
|
|
// set default values for GroupConfig
|
|
func (c *GroupConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
type rawGroupConfig GroupConfig
|
|
defaults := rawGroupConfig{
|
|
Swap: true,
|
|
Exclusive: true,
|
|
Persistent: false,
|
|
Members: []string{},
|
|
}
|
|
|
|
if err := unmarshal(&defaults); err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = GroupConfig(defaults)
|
|
return nil
|
|
}
|
|
|
|
type HooksConfig struct {
|
|
OnStartup HookOnStartup `yaml:"on_startup"`
|
|
}
|
|
|
|
type HookOnStartup struct {
|
|
Preload []string `yaml:"preload"`
|
|
}
|
|
|
|
type Config struct {
|
|
HealthCheckTimeout int `yaml:"healthCheckTimeout"`
|
|
LogRequests bool `yaml:"logRequests"`
|
|
LogLevel string `yaml:"logLevel"`
|
|
LogTimeFormat string `yaml:"logTimeFormat"`
|
|
LogToStdout string `yaml:"logToStdout"`
|
|
MetricsMaxInMemory int `yaml:"metricsMaxInMemory"`
|
|
CaptureBuffer int `yaml:"captureBuffer"`
|
|
Performance PerformanceConfig `yaml:"performance"`
|
|
GlobalTTL int `yaml:"globalTTL"`
|
|
Models map[string]ModelConfig `yaml:"models"` /* key is model ID */
|
|
Profiles map[string][]string `yaml:"profiles"`
|
|
|
|
// routing is the canonical source for swap/scheduling configuration.
|
|
// New code must read Routing, never the backwards-compat fields below.
|
|
Routing RoutingConfig `yaml:"routing"`
|
|
|
|
// Groups and Matrix are permanent backwards-compat input fields for the
|
|
// legacy top-level `groups:`/`matrix:` keys. They are normalized into
|
|
// Routing by LoadConfigFromReader. New code must not read them directly.
|
|
Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */
|
|
Matrix *MatrixConfig `yaml:"matrix"`
|
|
|
|
// for key/value replacements in model's cmd, cmdStop, proxy, checkEndPoint
|
|
Macros MacroList `yaml:"macros"`
|
|
|
|
// map aliases to actual model IDs
|
|
aliases map[string]string
|
|
|
|
// automatic port assignments
|
|
StartPort int `yaml:"startPort"`
|
|
|
|
// hooks, see: #209
|
|
Hooks HooksConfig `yaml:"hooks"`
|
|
|
|
// send loading state in reasoning
|
|
SendLoadingState bool `yaml:"sendLoadingState"`
|
|
|
|
// present aliases to /v1/models OpenAI API listing
|
|
IncludeAliasesInList bool `yaml:"includeAliasesInList"`
|
|
|
|
// support API keys, see issue #433, #50, #251
|
|
RequiredAPIKeys []string `yaml:"apiKeys"`
|
|
|
|
// support remote peers, see issue #433, #296
|
|
Peers PeerDictionaryConfig `yaml:"peers"`
|
|
|
|
// upstream controls behaviour of the /upstream passthrough endpoint
|
|
Upstream UpstreamConfig `yaml:"upstream"`
|
|
}
|
|
|
|
// RoutingConfig is the canonical, normalized routing/scheduling configuration.
|
|
type RoutingConfig struct {
|
|
Scheduler SchedulerConfig `yaml:"scheduler"`
|
|
Router RouterConfig `yaml:"router"`
|
|
}
|
|
|
|
type SchedulerConfig struct {
|
|
Use string `yaml:"use"` // default "fifo"
|
|
Settings SchedulerSettings `yaml:"settings"`
|
|
}
|
|
|
|
type SchedulerSettings struct {
|
|
Fifo FifoConfig `yaml:"fifo"`
|
|
Serial SerialConfig `yaml:"serial"`
|
|
}
|
|
|
|
type FifoConfig struct {
|
|
Priority map[string]int `yaml:"priority"` // model ID -> priority, default 0
|
|
}
|
|
|
|
// Serial scheduler scoring defaults. See SerialConfig.
|
|
const (
|
|
// DefaultAgingDivisor gives a waiting request one priority point per
|
|
// minute, so a batch job at -100 overtakes normal traffic after ~100
|
|
// minutes rather than starving behind it forever.
|
|
DefaultAgingDivisor = 60
|
|
|
|
// DefaultSwapAffinityBonus is large enough to break near-ties in favour
|
|
// of the already-loaded model (cold loads cost 2-3x) and far too small to
|
|
// promote a request across a 100-point priority band.
|
|
DefaultSwapAffinityBonus = 10
|
|
)
|
|
|
|
// SerialConfig tunes how the serial scheduler scores queued requests. At each
|
|
// dispatch it picks the highest scoring waiting request, where
|
|
//
|
|
// score = request priority + swap affinity + aging
|
|
//
|
|
// Both terms are pointers so an explicit 0 (disable) is distinguishable from
|
|
// "unset" (use the default); read them through the accessors below.
|
|
type SerialConfig struct {
|
|
// AgingDivisor is how many seconds a request must wait to gain one
|
|
// priority point. Aging is unbounded on purpose — it is the only term
|
|
// allowed to promote a request across a band, which is what stops a
|
|
// low-priority job from starving. 0 disables aging.
|
|
AgingDivisor *int `yaml:"agingDivisor"`
|
|
|
|
// SwapAffinityBonus is added to a request that can be served without
|
|
// swapping models, so strict priority does not force a cold load on every
|
|
// dispatch. Bounded well below the 100-point band spacing so it can only
|
|
// break near-ties, never promote batch work above interactive. 0 disables
|
|
// it, restoring strict priority-then-arrival order.
|
|
SwapAffinityBonus *int `yaml:"swapAffinityBonus"`
|
|
}
|
|
|
|
// GetAgingDivisor returns the configured aging divisor in seconds, or
|
|
// DefaultAgingDivisor when unset.
|
|
func (c SerialConfig) GetAgingDivisor() int {
|
|
if c.AgingDivisor == nil {
|
|
return DefaultAgingDivisor
|
|
}
|
|
return *c.AgingDivisor
|
|
}
|
|
|
|
// GetSwapAffinityBonus returns the configured swap affinity bonus, or
|
|
// DefaultSwapAffinityBonus when unset.
|
|
func (c SerialConfig) GetSwapAffinityBonus() int {
|
|
if c.SwapAffinityBonus == nil {
|
|
return DefaultSwapAffinityBonus
|
|
}
|
|
return *c.SwapAffinityBonus
|
|
}
|
|
|
|
type RouterConfig struct {
|
|
Use string `yaml:"use"` // "group" (default) | "matrix"
|
|
Settings RouterSettings `yaml:"settings"`
|
|
}
|
|
|
|
type RouterSettings struct {
|
|
Groups map[string]GroupConfig `yaml:"groups"`
|
|
Matrix *MatrixConfig `yaml:"matrix"`
|
|
}
|
|
|
|
func (c *Config) RealModelName(search string) (string, bool) {
|
|
if _, found := c.Models[search]; found {
|
|
return search, true
|
|
} else if name, found := c.aliases[search]; found {
|
|
return name, found
|
|
} else {
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (c *Config) FindConfig(modelName string) (ModelConfig, string, bool) {
|
|
if realName, found := c.RealModelName(modelName); !found {
|
|
return ModelConfig{}, "", false
|
|
} else {
|
|
return c.Models[realName], realName, true
|
|
}
|
|
}
|
|
|
|
func LoadConfig(path string) (Config, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
defer file.Close()
|
|
return LoadConfigFromReader(file)
|
|
}
|
|
|
|
// rewrites the yaml to include a default group with any orphaned models
|
|
func AddDefaultGroupToConfig(config Config) Config {
|
|
|
|
if config.Groups == nil {
|
|
config.Groups = make(map[string]GroupConfig)
|
|
}
|
|
|
|
defaultGroup := GroupConfig{
|
|
Swap: true,
|
|
Exclusive: true,
|
|
Members: []string{},
|
|
}
|
|
// if groups is empty, create a default group and put
|
|
// all models into it
|
|
if len(config.Groups) == 0 {
|
|
for modelName := range config.Models {
|
|
defaultGroup.Members = append(defaultGroup.Members, modelName)
|
|
}
|
|
} else {
|
|
// iterate over existing group members and add non-grouped models into the default group
|
|
for modelName := range config.Models {
|
|
foundModel := false
|
|
found:
|
|
// search for the model in existing groups
|
|
for _, groupConfig := range config.Groups {
|
|
for _, member := range groupConfig.Members {
|
|
if member == modelName {
|
|
foundModel = true
|
|
break found
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundModel {
|
|
defaultGroup.Members = append(defaultGroup.Members, modelName)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Strings(defaultGroup.Members) // make consistent ordering for testing
|
|
config.Groups[DEFAULT_GROUP_ID] = defaultGroup
|
|
|
|
return config
|
|
}
|