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) }