internal/autogen/sidecar.go
Rama main · réplica de lectura del repositorio original.
package autogen
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"github.com/quartermaster-labs/quartermaster/internal/config"
"gopkg.in/yaml.v3"
)
// BuiltinPlaygroundKeyName is the reserved name of the auto-managed, full-access
// API key the local Playground uses so it can reach every model even when all
// user-defined keys are model-scoped. It is hidden from the management UI and
// reconciled by EnsureSidecarPlaygroundKey.
const BuiltinPlaygroundKeyName = "playground (built-in)"
// EnsureSidecarPlaygroundKey reconciles the reserved Playground key against the
// user's keys and persists any change. Rules:
// - no user keys => no playground key (auth stays off)
// - user keys, none full => mint/keep the playground key (full access)
// - a user full-access key => drop the playground key (redundant)
//
// Returns the resulting list and whether the sidecar was rewritten.
func EnsureSidecarPlaygroundKey(generatePath string) (keys []APIKeyEntry, changed bool, err error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, false, err
}
hasBuiltin, userKeys, userUnscoped := false, 0, false
for _, k := range sc.APIKeys {
if strings.EqualFold(k.Name, BuiltinPlaygroundKeyName) {
hasBuiltin = true
continue
}
userKeys++
if len(k.Models) == 0 {
userUnscoped = true
}
}
wantBuiltin := userKeys > 0 && !userUnscoped
if wantBuiltin == hasBuiltin {
return sc.APIKeys, false, nil
}
if wantBuiltin {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return nil, false, err
}
sc.APIKeys = append(sc.APIKeys, APIKeyEntry{Name: BuiltinPlaygroundKeyName, Key: "qm-" + hex.EncodeToString(b)})
} else {
kept := sc.APIKeys[:0:0]
for _, k := range sc.APIKeys {
if !strings.EqualFold(k.Name, BuiltinPlaygroundKeyName) {
kept = append(kept, k)
}
}
sc.APIKeys = kept
}
if err := writeSidecar(generatePath, sc); err != nil {
return nil, false, err
}
return sc.APIKeys, true, nil
}
// SidecarName is the UI-managed overrides file. It sits next to the generate
// control file and is fully owned by the web UI's per-model editor, so it can
// be rewritten with yaml.Marshal without disturbing the hand-authored,
// comment-rich generate file. Its rows are merged ahead of the generate file's
// overrides (first match wins => UI edits take precedence).
const SidecarName = "quartermaster-overrides.yaml"
// sidecar is the on-disk shape of the UI-owned file: per-model overrides plus an
// optional global settings patch (targetVramGB / headroom edits from the
// dashboard). Both are UI-managed, so the file is rewritten whole on any change.
type sidecar struct {
Settings *SettingsPatch `yaml:"settings,omitempty"`
// DefaultVariants, when non-nil, replaces the generate file's fleet-wide
// settings.defaultVariants wholesale (the UI sends the full list). Kept at the
// top level rather than inside SettingsPatch so a dashboard VRAM "reset" can't
// wipe it. ponytail: omitempty means an empty list isn't persisted, so removing
// the last fleet-wide variant from the UI reverts to the file — edit the
// generate file to delete one entirely.
DefaultVariants []VariantSpec `yaml:"defaultVariants,omitempty"`
// CategoryRoots are the UI folder-picker's per-category scan folders. Top-level
// (not in SettingsPatch) so a dashboard VRAM reset preserves them. omitempty =>
// clearing the last entry reverts to the generate file's categoryRoots.
CategoryRoots map[string]string `yaml:"categoryRoots,omitempty"`
// DisplayNames maps a base served id -> a UI-chosen advertised name (cascades
// to variant ids). Top-level so a VRAM reset can't wipe it. omitempty =>
// clearing the last rename reverts to the generate file.
DisplayNames map[string]string `yaml:"displayNames,omitempty"`
// APIKeys, when non-nil, replaces the generate file's apiKeys wholesale (the
// UI sends the full list). omitempty => deleting the last key reverts to the
// file's apiKeys.
APIKeys []APIKeyEntry `yaml:"apiKeys,omitempty"`
// SlotCache, when non-nil, replaces the generate file's settings.slotCache
// block (the dashboard's slot-KV section sends the whole block). Top-level (not
// in SettingsPatch) so a VRAM reset can't wipe it. omitempty => never written
// until the dashboard saves it.
SlotCache *SlotCacheSettings `yaml:"slotCache,omitempty"`
// Backends, when non-nil, overrides the generate file's backend executable
// paths (llama-server / sd-server / tts-server). Top-level (not in
// SettingsPatch) so a VRAM reset can't wipe it. Per-field empty => inherit the
// generate file / sibling default. Lets the dashboard point at a Vulkan/ROCm
// build on non-NVIDIA GPUs without hand-editing the generate file.
Backends *BackendExes `yaml:"backends,omitempty"`
// BackendList is the dashboard's full backend registry (multiple llama.cpp /
// sd / tts / vllm / custom entries). The legacy `Backends` block above is
// DERIVED from it (first entry per kind) so autogen keeps consuming three
// exes; extra entries are stored for future per-model wiring but currently
// unused by generation.
BackendList []BackendEntry `yaml:"backendList,omitempty"`
// BackendSources are user-tracked GitHub repos the in-app installer manages
// alongside its built-in catalog. Separate from BackendList: a source is a
// *place to download builds from*, a BackendEntry is an executable path the
// launcher uses. Installing from a source writes the entry.
BackendSources []BackendSource `yaml:"backendSources,omitempty"`
// App is the process-level block (ports, remote access, update polling, HF
// token) the dashboard's network section owns. Top-level and outside
// SettingsPatch for the usual reason — a VRAM reset must not change which
// socket the next start binds — and read by main() before the config exists.
// See appsettings.go.
// PromptEnhancers, when non-nil, replaces the generate file's
// settings.promptEnhancers wholesale (the Settings page sends the full list).
// Top-level for the usual reason - a dashboard VRAM reset must not delete the
// user's system prompts - and omitempty so removing the last enhancer reverts
// to whatever the generate file declares.
PromptEnhancers []PromptEnhancer `yaml:"promptEnhancers,omitempty"`
// ExtraImageModels are image models added by hand from the Settings page
// (safetensors DiTs the scan cannot classify). Unlike promptEnhancers these
// EXTEND the generate file's settings.extraImageModels instead of replacing
// it: a file entry is a power user's hand-written block the UI never showed
// as its own, so saving the table must not copy it here or delete it. A
// sidecar row whose name matches a file row wins (see mergeExtraImageModels).
ExtraImageModels []ExtraImageModel `yaml:"extraImageModels,omitempty"`
// RemovedExtraImageModels are generate-file extra models deleted from the
// Settings table. The file is the user's hand-edited source and is never
// rewritten, so a delete is recorded here and applied at load instead.
RemovedExtraImageModels []string `yaml:"removedExtraImageModels,omitempty"`
App *AppSettings `yaml:"app,omitempty"`
Overrides []Override `yaml:"overrides"`
}
// BackendExes holds the dashboard-editable backend executable paths. Empty field
// => inherit the generate file value / applyDefaults sibling.
type BackendExes struct {
ServerExe string `yaml:"serverExe,omitempty"`
SdServerExe string `yaml:"sdServerExe,omitempty"`
TtsServerExe string `yaml:"ttsServerExe,omitempty"`
AsrServerExe string `yaml:"asrServerExe,omitempty"`
}
// BackendEntry is one row in the dashboard's backend registry. Kind drives how a
// backend is launched (currently only llama/sd/tts feed autogen; vllm/custom are
// stored for later wiring). Path is the executable (or launch command) to run.
type BackendEntry struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"` // llama | sd | tts | asr | vllm | custom
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
// Default marks this entry as the auto-pick for its model class (one per
// class: ★ "default for LLMs"). A model with no explicit backend resolves to
// the class-default entry, else the first entry of that class.
Default bool `yaml:"default,omitempty"`
// Managed marks a row whose binary was downloaded by the in-app backend
// manager (internal/backends) rather than pointed at by hand. Component +
// Version + Variant identify which build, so an update can repoint this row
// and the UI can offer a rollback to another installed version. A manual row
// leaves all four zero and is never touched by the manager.
Managed bool `yaml:"managed,omitempty"`
Component string `yaml:"component,omitempty"`
Version string `yaml:"version,omitempty"`
Variant string `yaml:"variant,omitempty"`
// Build marks a DERIVED row: one build the manager has on disk, so the model
// editor can pin any installed build and not just the activated one. The
// installer keeps one row per build (same Component, differing Version and
// Variant), written alongside the Managed row, and the settings editor hides
// them: they exist to be referenced by Override.Backend, not edited.
Build bool `yaml:"build,omitempty"`
}
// BackendSource is one user-tracked GitHub repo: a backend the in-app installer
// can download and keep updated even though it is not in the built-in catalog
// (a llama.cpp fork with different GPU builds, a private engine, an in-house
// server). It is the persisted half of internal/backends' Component — the
// installer merges these on top of its static table.
//
// The user never writes an asset regex. They pick the asset they want out of a
// real release and the installer derives Pattern from it, keeping Asset as the
// example it came from so the pattern can be re-derived or diagnosed later.
type BackendSource struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Blurb string `yaml:"blurb,omitempty"`
Repo string `yaml:"repo"` // owner/name on github.com
Kind string `yaml:"kind"` // registry kind: llama | sd | tts | asr | …
Exe string `yaml:"exe"` // executable to locate inside the archive
Bare bool `yaml:"bare,omitempty"` // the asset IS the executable
// AllowPrerelease lets "latest" resolve to a prerelease. Set automatically
// for repos that publish nothing but nightlies, which would otherwise never
// resolve to an installable release.
AllowPrerelease bool `yaml:"allowPrerelease,omitempty"`
// OS is the GOOS the source was configured on. A tracked repo is set up by
// picking from the asset list of the machine doing the picking, so its
// patterns are only meaningful there; the installer scopes them to this OS
// rather than pretending they are portable.
OS string `yaml:"os,omitempty"`
Variants []BackendSourceVariant `yaml:"variants"`
}
// BackendSourceVariant is one build of a tracked source — a flavour the user
// picked, not a slot they had to fill in. Adding a second GPU build is another
// pick from the same list.
type BackendSourceVariant struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Asset string `yaml:"asset"` // the example asset name the user chose
Pattern string `yaml:"pattern"` // derived from Asset; never hand-written
// Extras are additional assets unpacked alongside the primary one (a
// separately-shipped GPU runtime). Same derivation, best-effort at install.
Extras []BackendSourceExtra `yaml:"extras,omitempty"`
}
// BackendSourceExtra is one companion asset of a variant.
type BackendSourceExtra struct {
Asset string `yaml:"asset"`
Pattern string `yaml:"pattern"`
}
// LoadSidecarBackendSources returns the user's tracked backend repos.
func LoadSidecarBackendSources(generatePath string) ([]BackendSource, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.BackendSources, nil
}
// UpsertSidecarBackendSources replaces the tracked-repo list wholesale (the UI
// sends all of it). Rows with no repo, no id or no usable variant are dropped:
// they carry nothing installable and would only show up as a dead card.
func UpsertSidecarBackendSources(generatePath string, list []BackendSource) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
cleaned := make([]BackendSource, 0, len(list))
for _, s := range list {
s.ID = strings.TrimSpace(s.ID)
s.Repo = strings.TrimSpace(s.Repo)
s.Kind = strings.TrimSpace(s.Kind)
s.Exe = strings.TrimSpace(s.Exe)
s.Name = strings.TrimSpace(s.Name)
if s.ID == "" || s.Repo == "" {
continue
}
vs := make([]BackendSourceVariant, 0, len(s.Variants))
for _, v := range s.Variants {
if strings.TrimSpace(v.Pattern) == "" {
continue
}
vs = append(vs, v)
}
if len(vs) == 0 {
continue
}
s.Variants = vs
cleaned = append(cleaned, s)
}
if len(cleaned) == 0 {
sc.BackendSources = nil
} else {
sc.BackendSources = cleaned
}
return writeSidecar(generatePath, sc)
}
// LoadSidecarPromptEnhancers returns the UI-owned prompt-enhancer list, or nil
// when the sidecar has none (in which case the generate file's own
// settings.promptEnhancers stands).
func LoadSidecarPromptEnhancers(generatePath string) ([]PromptEnhancer, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.PromptEnhancers, nil
}
// UpsertSidecarPromptEnhancers replaces the enhancer list wholesale (the
// Settings page sends all of it).
//
// Rows naming no model are dropped, and a duplicate model id keeps the LAST
// occurrence: the list is keyed by model id downstream (one enhancer per model),
// so two rows for one id are an edit that was appended rather than replaced, and
// letting both persist would make the generated config depend on map iteration
// order. Order is otherwise preserved, since the UI renders the list as written.
//
// The system prompt is NOT trimmed. Leading indentation and a trailing newline
// are part of a published PE prompt, and quietly reshaping it changes what the
// model was trained to see.
func UpsertSidecarPromptEnhancers(generatePath string, list []PromptEnhancer) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
seen := map[string]int{}
cleaned := make([]PromptEnhancer, 0, len(list))
for _, e := range list {
e.Model = strings.TrimSpace(e.Model)
e.Name = strings.TrimSpace(e.Name)
if e.Model == "" {
continue
}
if at, dup := seen[strings.ToLower(e.Model)]; dup {
cleaned[at] = e
continue
}
seen[strings.ToLower(e.Model)] = len(cleaned)
cleaned = append(cleaned, e)
}
if len(cleaned) == 0 {
sc.PromptEnhancers = nil
} else {
sc.PromptEnhancers = cleaned
}
return writeSidecar(generatePath, sc)
}
// LoadSidecarExtraImageModels returns the UI-added image models, or nil when
// the sidecar has none.
func LoadSidecarExtraImageModels(generatePath string) ([]ExtraImageModel, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.ExtraImageModels, nil
}
// LoadSidecarRemovedExtraImageModels returns the names of generate-file extra
// models the Settings table deleted.
func LoadSidecarRemovedExtraImageModels(generatePath string) ([]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.RemovedExtraImageModels, nil
}
// UpsertSidecarExtraImageModels replaces the UI-owned extra-model list and the
// removed-file-row list wholesale. Rows missing a name or model path are
// dropped, and a duplicate name keeps the LAST occurrence in the first one's
// position, for the same reason as UpsertSidecarPromptEnhancers: the served id
// is the key downstream. A removed name that is also a kept row is dropped from
// the removed list, since re-adding a row under that name undoes the delete.
func UpsertSidecarExtraImageModels(generatePath string, list []ExtraImageModel, removed []string) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
seen := map[string]int{}
cleaned := make([]ExtraImageModel, 0, len(list))
for _, m := range list {
m.Name = strings.TrimSpace(m.Name)
m.ModelPath = strings.TrimSpace(m.ModelPath)
if m.Name == "" || m.ModelPath == "" {
continue
}
key := strings.ToLower(m.Name)
if at, dup := seen[key]; dup {
cleaned[at] = m
continue
}
seen[key] = len(cleaned)
cleaned = append(cleaned, m)
}
if len(cleaned) == 0 {
sc.ExtraImageModels = nil
} else {
sc.ExtraImageModels = cleaned
}
sc.RemovedExtraImageModels = nil
for _, n := range removed {
n = strings.TrimSpace(n)
if _, kept := seen[strings.ToLower(n)]; n == "" || kept ||
slices.ContainsFunc(sc.RemovedExtraImageModels, func(r string) bool { return strings.EqualFold(r, n) }) {
continue
}
sc.RemovedExtraImageModels = append(sc.RemovedExtraImageModels, n)
}
return writeSidecar(generatePath, sc)
}
// FileExtraImageModelNames returns the names the generate file itself declares
// under settings.extraImageModels, before any sidecar row shadows or removes
// one. The Settings table needs them to tell "deleted a file row" apart from
// "deleted a UI row", which LoadGenerateFile's merged view cannot.
func FileExtraImageModelNames(generatePath string) ([]string, error) {
data, err := os.ReadFile(generatePath)
if err != nil {
return nil, err
}
var gf GenerateFile
if err := yaml.Unmarshal(data, &gf); err != nil {
return nil, fmt.Errorf("parsing %s: %w", generatePath, err)
}
names := make([]string, 0, len(gf.Settings.ExtraImageModels))
for _, m := range gf.Settings.ExtraImageModels {
if n := strings.TrimSpace(m.Name); n != "" {
names = append(names, n)
}
}
return names, nil
}
// mergeExtraImageModels drops the file rows the Settings table deleted, then
// overlays the UI-added models: a UI row replaces the file row of the same name
// in place, the rest append.
func mergeExtraImageModels(file, side []ExtraImageModel, removed []string) []ExtraImageModel {
if len(side) == 0 && len(removed) == 0 {
return file
}
out := slices.DeleteFunc(slices.Clone(file), func(f ExtraImageModel) bool {
return slices.ContainsFunc(removed, func(r string) bool { return strings.EqualFold(r, strings.TrimSpace(f.Name)) })
})
for _, m := range side {
at := slices.IndexFunc(out, func(f ExtraImageModel) bool {
return strings.EqualFold(strings.TrimSpace(f.Name), m.Name)
})
if at >= 0 {
out[at] = m
} else {
out = append(out, m)
}
}
return out
}
// loadSidecar reads the whole sidecar, returning a zero value when absent.
func loadSidecar(generatePath string) (sidecar, error) {
data, err := os.ReadFile(SidecarPath(generatePath))
if err != nil {
if os.IsNotExist(err) {
return sidecar{}, nil
}
return sidecar{}, err
}
var sc sidecar
if err := yaml.Unmarshal(data, &sc); err != nil {
return sidecar{}, fmt.Errorf("parsing %s: %w", SidecarName, err)
}
return sc, nil
}
// writeSidecar persists the whole sidecar (UI-owned, plain marshal).
func writeSidecar(generatePath string, sc sidecar) error {
out, err := yaml.Marshal(sc)
if err != nil {
return err
}
return os.WriteFile(SidecarPath(generatePath), out, 0o644)
}
// SidecarPath returns the sidecar path for a given generate control file path
// (same directory, fixed name).
func SidecarPath(generatePath string) string {
return filepath.Join(filepath.Dir(generatePath), SidecarName)
}
// LoadSidecarOverrides reads the sidecar overrides, returning an empty slice
// when the file is absent.
func LoadSidecarOverrides(generatePath string) ([]Override, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.Overrides, nil
}
// LoadSidecarSettings returns the sidecar's global settings patch, or nil when
// none is set.
func LoadSidecarSettings(generatePath string) (*SettingsPatch, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.Settings, nil
}
// LoadSidecarSlotCache returns the sidecar's slot-KV block, or nil when unset.
func LoadSidecarSlotCache(generatePath string) (*SlotCacheSettings, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.SlotCache, nil
}
// LoadSidecarBackends returns the sidecar's backend-exe overrides, or nil when
// none is set.
func LoadSidecarBackends(generatePath string) (*BackendExes, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.Backends, nil
}
// UpsertSidecarBackends stores the dashboard's backend-exe overrides, preserving
// the rest of the sidecar. An all-empty value is cleared (reverts to defaults).
func UpsertSidecarBackends(generatePath string, be BackendExes) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
if be.ServerExe == "" && be.SdServerExe == "" && be.TtsServerExe == "" && be.AsrServerExe == "" {
sc.Backends = nil
} else {
sc.Backends = &be
}
return writeSidecar(generatePath, sc)
}
// LoadSidecarBackendList returns the dashboard's full backend registry, or nil
// when none is set.
func LoadSidecarBackendList(generatePath string) ([]BackendEntry, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.BackendList, nil
}
// deriveBackendExes maps the registry onto the three legacy exes autogen
// consumes: the first entry of each kind wins. Alias kinds are accepted so a
// hand-edited sidecar still resolves.
func deriveBackendExes(list []BackendEntry) BackendExes {
var be BackendExes
// A ★Default entry wins over first-seen for its class (matches resolveBackend's
// llm contract). Track whether each slot was set by a default so a later
// default can't be clobbered but a first-seen fallback can be upgraded.
var serverDef, sdDef, ttsDef, asrDef bool
set := func(slot *string, isDef *bool, path string, entryDef bool) {
p := strings.TrimSpace(path)
if p == "" {
return
}
if *slot == "" || (entryDef && !*isDef) {
*slot = p
*isDef = entryDef
}
}
for _, e := range list {
switch strings.ToLower(strings.TrimSpace(e.Kind)) {
case "llama", "llama.cpp", "server":
set(&be.ServerExe, &serverDef, e.Path, e.Default)
case "sd", "sd-server", "image":
set(&be.SdServerExe, &sdDef, e.Path, e.Default)
case "tts", "tts-server", "speech":
set(&be.TtsServerExe, &ttsDef, e.Path, e.Default)
case "asr", "parakeet", "parakeet-server", "transcribe":
set(&be.AsrServerExe, &asrDef, e.Path, e.Default)
}
}
return be
}
// UpsertSidecarBackendList stores the dashboard's backend registry, preserving
// the rest of the sidecar, and re-derives the legacy `Backends` block (first
// entry per kind) so autogen keeps resolving exes. Blank-path rows are dropped;
// an empty result clears both fields (revert to defaults).
func UpsertSidecarBackendList(generatePath string, list []BackendEntry) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
cleaned := make([]BackendEntry, 0, len(list))
for _, e := range list {
e.Kind = strings.TrimSpace(e.Kind)
e.Name = strings.TrimSpace(e.Name)
e.Path = strings.TrimSpace(e.Path)
e.ID = strings.TrimSpace(e.ID)
if e.Path == "" {
continue // a row with no exe carries nothing to launch
}
cleaned = append(cleaned, e)
}
if len(cleaned) == 0 {
sc.BackendList = nil
sc.Backends = nil
return writeSidecar(generatePath, sc)
}
sc.BackendList = cleaned
be := deriveBackendExes(cleaned)
if be.ServerExe == "" && be.SdServerExe == "" && be.TtsServerExe == "" && be.AsrServerExe == "" {
sc.Backends = nil
} else {
sc.Backends = &be
}
return writeSidecar(generatePath, sc)
}
// UpsertSidecarSlotCache stores the dashboard's slot-KV block, preserving the
// rest of the sidecar.
func UpsertSidecarSlotCache(generatePath string, s SlotCacheSettings) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
sc.SlotCache = &s
return writeSidecar(generatePath, sc)
}
// UpsertSidecarOverride inserts or replaces (by Match, case-insensitive) the
// override for one model in the sidecar. Returns the resulting list.
func UpsertSidecarOverride(generatePath string, ov Override) ([]Override, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
replaced := false
for i := range sc.Overrides {
if matchKeyEqual(sc.Overrides[i].Match, ov.Match) {
sc.Overrides[i] = ov
replaced = true
break
}
}
if !replaced {
sc.Overrides = append(sc.Overrides, ov)
}
if err := writeSidecar(generatePath, sc); err != nil {
return nil, err
}
return sc.Overrides, nil
}
// DeleteSidecarOverride removes the sidecar override matching match (the
// "reset to default" action). Returns whether a row was removed.
func DeleteSidecarOverride(generatePath, match string) (removed bool, err error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return false, err
}
kept := sc.Overrides[:0:0]
for _, r := range sc.Overrides {
if matchKeyEqual(r.Match, match) {
removed = true
continue
}
kept = append(kept, r)
}
if !removed {
return false, nil
}
sc.Overrides = kept
if err := writeSidecar(generatePath, sc); err != nil {
return false, err
}
return true, nil
}
// matchIsDeadPath reports whether an override's Match is a concrete gguf file
// path (not a glob) that no longer exists on disk. Glob patterns (containing
// `*`/`?`) and bare name fragments are never considered dead — only explicit
// file paths the UI wrote for a single model can be pruned safely.
func matchIsDeadPath(match string) bool {
if strings.ContainsAny(match, "*?") {
return false
}
if !filepath.IsAbs(match) && !strings.HasSuffix(strings.ToLower(match), ".gguf") {
return false
}
_, err := os.Stat(filepath.FromSlash(match))
return os.IsNotExist(err)
}
// PruneSidecar drops sidecar overrides whose explicit-path Match points at a
// gguf that no longer exists on disk, so deleting a model file also reaps its
// per-model override on the next config regen. Glob overrides and fleet-wide
// defaults are left untouched. Returns the matches removed (nil when none) and
// writes the sidecar back only when something changed.
func PruneSidecar(generatePath string) ([]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
var removed []string
kept := sc.Overrides[:0:0]
for _, r := range sc.Overrides {
if matchIsDeadPath(r.Match) {
removed = append(removed, r.Match)
continue
}
kept = append(kept, r)
}
if len(removed) == 0 {
return nil, nil
}
sc.Overrides = kept
if err := writeSidecar(generatePath, sc); err != nil {
return nil, err
}
return removed, nil
}
// LoadSidecarDefaultVariants returns the sidecar's fleet-wide default-variant
// list, or nil when none is set (inherit the generate file's).
func LoadSidecarDefaultVariants(generatePath string) ([]VariantSpec, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.DefaultVariants, nil
}
// UpsertSidecarDefaultVariants replaces the fleet-wide default-variant list in
// the sidecar (UI sends the full list), preserving overrides + settings patch.
func UpsertSidecarDefaultVariants(generatePath string, vs []VariantSpec) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
sc.DefaultVariants = vs
return writeSidecar(generatePath, sc)
}
// LoadSidecarDisplayNames returns the sidecar's base-id -> advertised-name map,
// or nil when none are set.
func LoadSidecarDisplayNames(generatePath string) (map[string]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.DisplayNames, nil
}
// UpsertSidecarDisplayName sets (or, when name is "", clears) the advertised name
// for one base served id, preserving the rest of the sidecar. Returns the map.
func UpsertSidecarDisplayName(generatePath, baseID, name string) (map[string]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
if strings.TrimSpace(name) == "" {
delete(sc.DisplayNames, baseID)
} else {
if sc.DisplayNames == nil {
sc.DisplayNames = map[string]string{}
}
sc.DisplayNames[baseID] = strings.TrimSpace(name)
}
if err := writeSidecar(generatePath, sc); err != nil {
return nil, err
}
return sc.DisplayNames, nil
}
// LoadSidecarCategoryRoots returns the sidecar's per-category scan folders, or
// nil when none are set.
func LoadSidecarCategoryRoots(generatePath string) (map[string]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.CategoryRoots, nil
}
// UpsertSidecarRoot sets (or, when path is "", clears) the scan folder for one
// category, preserving overrides + settings. Returns the resulting map.
func UpsertSidecarRoot(generatePath, category, path string) (map[string]string, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
if strings.TrimSpace(path) == "" {
delete(sc.CategoryRoots, category)
} else {
if sc.CategoryRoots == nil {
sc.CategoryRoots = map[string]string{}
}
sc.CategoryRoots[category] = path
}
if err := writeSidecar(generatePath, sc); err != nil {
return nil, err
}
return sc.CategoryRoots, nil
}
// UpsertSidecarLoraDir sets (or, when path is "", clears) the per-category LoRA
// folder in the UI-owned settings patch. Read-modify-write rather than a plain
// Upsert: LoraDirs is one map holding every category, so writing just this key
// would otherwise drop the others.
//
// The map is copied before mutation because LoadSidecarSettings hands back the
// stored patch itself, and MergeSettingsPatch keeps the same map by reference —
// mutating it in place would edit prev and next at once, making the "explicit
// clear" case indistinguishable from "untouched".
func UpsertSidecarLoraDir(generatePath, category, path string) error {
patch, err := LoadSidecarSettings(generatePath)
if err != nil {
return err
}
dirs := map[string]string{}
if patch != nil && patch.LoraDirs != nil {
for k, v := range *patch.LoraDirs {
dirs[k] = v
}
}
if strings.TrimSpace(path) == "" {
delete(dirs, category)
} else {
dirs[category] = path
}
return UpsertSidecarSettings(generatePath, SettingsPatch{LoraDirs: &dirs})
}
// LoadSidecarAPIKeys returns the sidecar's API-key list, or nil when none is
// set (inherit the generate file's apiKeys).
func LoadSidecarAPIKeys(generatePath string) ([]APIKeyEntry, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.APIKeys, nil
}
// UpsertSidecarAPIKey inserts or replaces (by Name, case-insensitive) one API
// key, preserving the rest of the sidecar. Returns the resulting list.
func UpsertSidecarAPIKey(generatePath string, entry APIKeyEntry) ([]APIKeyEntry, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
replaced := false
for i := range sc.APIKeys {
if strings.EqualFold(sc.APIKeys[i].Name, entry.Name) {
sc.APIKeys[i] = entry
replaced = true
break
}
}
if !replaced {
sc.APIKeys = append(sc.APIKeys, entry)
}
if err := writeSidecar(generatePath, sc); err != nil {
return nil, err
}
return sc.APIKeys, nil
}
// DeleteSidecarAPIKey removes the API key with the given name (case-insensitive).
// Returns whether a row was removed.
func DeleteSidecarAPIKey(generatePath, name string) (removed bool, err error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return false, err
}
kept := sc.APIKeys[:0:0]
for _, k := range sc.APIKeys {
if strings.EqualFold(k.Name, name) {
removed = true
continue
}
kept = append(kept, k)
}
if !removed {
return false, nil
}
sc.APIKeys = kept
if err := writeSidecar(generatePath, sc); err != nil {
return false, err
}
return true, nil
}
// UpsertSidecarSettings MERGES the global settings patch (dashboard edits) into
// the stored one, preserving existing per-model overrides.
//
// The merge is the contract, not an optimisation: the dashboard writes this
// patch from several independent sections (memory, guards, GPU usage, advanced),
// each PUTting only the fields it owns. A whole-struct replace here means saving
// any one section silently reverts every other — see MergeSettingsPatch.
func UpsertSidecarSettings(generatePath string, patch SettingsPatch) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
if sc.Settings != nil {
patch = MergeSettingsPatch(*sc.Settings, patch)
}
sc.Settings = &patch
return writeSidecar(generatePath, sc)
}
// ReplaceSidecarSettings stores the patch VERBATIM, without merging the stored
// one forward. It is how a section clears its own fields: MergeSettingsPatch can
// only ever set a field, so "restore this section to defaults" — nil out its
// fields, keep everyone else's — is unexpressible through UpsertSidecarSettings.
//
// Callers must therefore hand it a patch derived from LoadSidecarSettings, not a
// freshly built one, or they drop every other section's settings.
func ReplaceSidecarSettings(generatePath string, patch SettingsPatch) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
// An all-nil patch is an empty block; drop the key so the sidecar reads as
// "nothing pinned" rather than carrying a stub that looks like a setting.
if patchIsEmpty(patch) {
sc.Settings = nil
} else {
sc.Settings = &patch
}
return writeSidecar(generatePath, sc)
}
// patchIsEmpty reports whether no field of the patch is set. Reflective for the
// same reason MergeSettingsPatch is: a hand-written field list rots the moment
// someone adds a knob, and the failure is silent.
func patchIsEmpty(p SettingsPatch) bool {
rv := reflect.ValueOf(p)
for i := 0; i < rv.NumField(); i++ {
if !rv.Field(i).IsNil() {
return false
}
}
return true
}
// ClearSidecarSettings removes the global settings patch (reset to default),
// preserving per-model overrides. Returns whether a patch was present.
func ClearSidecarSettings(generatePath string) (removed bool, err error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return false, err
}
if sc.Settings == nil {
return false, nil
}
sc.Settings = nil
if err := writeSidecar(generatePath, sc); err != nil {
return false, err
}
return true, nil
}
// matchKeyEqual compares two sidecar Match keys. UI-written keys are exact gguf
// paths (no globs), so path identity is the right test — separator-insensitive
// always, case-insensitive only where the filesystem is (see config.PathEqual).
func matchKeyEqual(a, b string) bool {
return config.PathEqual(a, b)
}