Ctrl K
ComunidadGuías…

internal/autogen/sidecar.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
}