From 9ed4f6a0767ef6e0aa2a20a903a8a6807201e2f4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 18 Jul 2026 11:18:23 +0000 Subject: [PATCH] ci(gallery): add nightly denormalization of meta model candidates Fills the read-only backend, quantization and inferred_min_vram fields on meta gallery candidates and opens a PR, modeled on the existing checksum_checker job. Computing these needs network access, so it happens nightly rather than at install time. An authored min_vram is never modified: a human who measured a real load knows more than a pre-download estimate does. The index is rewritten via yaml.Node rather than a document round-trip. A full round-trip reflows all ~26k lines of gallery/index.yaml, which would bury the computed values and make the nightly PR unreviewable. The rewrite touches only the three derived keys, so authored styling survives and a run that computes nothing leaves the file untouched. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- .github/ci/gallery_denormalize.go | 258 +++++++++++++++++++++ .github/workflows/gallery-denormalize.yaml | 30 +++ 2 files changed, 288 insertions(+) create mode 100644 .github/ci/gallery_denormalize.go create mode 100644 .github/workflows/gallery-denormalize.yaml diff --git a/.github/ci/gallery_denormalize.go b/.github/ci/gallery_denormalize.go new file mode 100644 index 000000000..12521a4c1 --- /dev/null +++ b/.github/ci/gallery_denormalize.go @@ -0,0 +1,258 @@ +//go:build ignore + +// Command gallery_denormalize fills the read-only denormalized fields on meta +// model entries: backend, quantization, and inferred_min_vram. +// +// It never modifies an authored min_vram. Authored values are authoritative, +// because a human who measured a real load knows more than a pre-download +// estimate does. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" + + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/vram" +) + +const estimateContext = 8192 + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: gallery_denormalize ") + os.Exit(1) + } + path := os.Args[1] + + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "read %s: %v\n", path, err) + os.Exit(1) + } + + var entries []gallery.GalleryModel + if err := yaml.Unmarshal(data, &entries); err != nil { + fmt.Fprintf(os.Stderr, "parse %s: %v\n", path, err) + os.Exit(1) + } + + byName := map[string]gallery.GalleryModel{} + for _, e := range entries { + byName[e.Name] = e + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + failures := 0 + for i := range entries { + if !entries[i].IsMeta() { + continue + } + for j := range entries[i].Candidates { + c := &entries[i].Candidates[j] + + target, ok := byName[c.Model] + if !ok { + fmt.Fprintf(os.Stderr, "%s: candidate %q not found\n", entries[i].Name, c.Model) + failures++ + continue + } + + // The concrete entry's payload usually lives behind its url + // rather than inline, so fetch it. Metadata.Backend is populated + // at load time by the running server, not by parsing the index, + // so it is always empty here and cannot be copied. + resolved, err := gallery.GetGalleryConfigFromURLWithContext[gallery.ModelConfig](ctx, target.URL, "") + if err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot fetch config for %q: %v\n", entries[i].Name, c.Model, err) + failures++ + continue + } + + c.Backend = backendOf(target, resolved) + c.Quantization = quantizationOf(target) + + // Authored values win, and the final unconstrained candidate is + // deliberately floorless, so neither gets an inferred value. + if c.MinVRAM != "" || j == len(entries[i].Candidates)-1 { + continue + } + + // Weight files can come from either side: a concrete index entry + // usually lists them itself (files:, i.e. AdditionalFiles) while + // the url it points at is only a base config, but some entries + // invert that. Install time downloads both, so estimate over both. + files := make([]vram.FileInput, 0, len(target.AdditionalFiles)+len(resolved.Files)) + for _, f := range target.AdditionalFiles { + files = append(files, vram.FileInput{URI: f.URI}) + } + for _, f := range resolved.Files { + files = append(files, vram.FileInput{URI: f.URI}) + } + + estimate, err := vram.EstimateModelMultiContext(ctx, vram.ModelEstimateInput{ + Files: files, + Size: target.Size, + }, []uint32{estimateContext}) + if err != nil || estimate.VRAMForContext(estimateContext) == 0 { + fmt.Fprintf(os.Stderr, + "%s: could not estimate min_vram for %q (%v); author one by hand\n", + entries[i].Name, c.Model, err) + failures++ + continue + } + c.InferredMinVRAM = fmt.Sprintf("%dMiB", estimate.VRAMForContext(estimateContext)/(1024*1024)) + } + } + + if err := writeCandidates(path, data, entries); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err) + os.Exit(1) + } + + if failures > 0 { + fmt.Fprintf(os.Stderr, "%d candidate(s) need a hand-authored min_vram\n", failures) + os.Exit(1) + } +} + +// writeCandidates writes back only the three derived keys on each candidate, +// leaving every other byte of the index alone. +// +// Marshalling the whole document back out would reflow all 1200+ entries +// (quoting, indentation, key order, line wrapping), burying the handful of +// real changes in reformatting noise and making the nightly PR unreviewable. +// Even re-encoding just the candidates subtree would restyle authored fields +// such as min_vram, so the rewrite reaches down to individual mapping keys. +// That also makes the job idempotent: a run that computes the same values +// leaves the file untouched and opens no PR. +func writeCandidates(path string, data []byte, entries []gallery.GalleryModel) error { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("re-parse for node rewrite: %w", err) + } + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return fmt.Errorf("unexpected document shape") + } + root := doc.Content[0] + if root.Kind != yaml.SequenceNode { + return fmt.Errorf("index root is not a sequence") + } + if len(root.Content) != len(entries) { + return fmt.Errorf("entry count drift: %d nodes vs %d entries", len(root.Content), len(entries)) + } + + changed := false + for i, entryNode := range root.Content { + if !entries[i].IsMeta() || entryNode.Kind != yaml.MappingNode { + continue + } + + candidatesNode := mappingValue(entryNode, "candidates") + if candidatesNode == nil || candidatesNode.Kind != yaml.SequenceNode { + return fmt.Errorf("entry %q has candidates but no candidates sequence in the document", entries[i].Name) + } + if len(candidatesNode.Content) != len(entries[i].Candidates) { + return fmt.Errorf("entry %q candidate count drift: %d nodes vs %d parsed", + entries[i].Name, len(candidatesNode.Content), len(entries[i].Candidates)) + } + + for j, candidateNode := range candidatesNode.Content { + if candidateNode.Kind != yaml.MappingNode { + return fmt.Errorf("entry %q candidate %d is not a mapping", entries[i].Name, j) + } + c := entries[i].Candidates[j] + for _, kv := range []struct{ key, value string }{ + {"backend", c.Backend}, + {"quantization", c.Quantization}, + {"inferred_min_vram", c.InferredMinVRAM}, + } { + if setMappingValue(candidateNode, kv.key, kv.value) { + changed = true + } + } + } + } + + // Re-encoding an untouched document still normalizes indentation, so skip + // the write entirely when there was nothing to rewrite. + if !changed { + return nil + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + return os.WriteFile(path, out, 0644) +} + +// mappingValue returns the value node for key, or nil when absent. +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// setMappingValue sets key to value, appending the key when absent and dropping +// it when value is empty so a field that stops being derivable does not leave a +// stale number behind. It reports whether the document actually changed. +func setMappingValue(mapping *yaml.Node, key, value string) bool { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value != key { + continue + } + if value == "" { + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + return true + } + if mapping.Content[i+1].Value == value { + return false + } + mapping.Content[i+1] = scalarNode(value) + return true + } + if value == "" { + return false + } + mapping.Content = append(mapping.Content, scalarNode(key), scalarNode(value)) + return true +} + +func scalarNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +// backendOf mirrors the priority used by resolveBackend in +// core/gallery/backend_resolve.go: an entry's overrides win over whatever the +// referenced config file declares. +func backendOf(m gallery.GalleryModel, resolved gallery.ModelConfig) string { + if b, ok := m.Overrides["backend"].(string); ok && b != "" { + return b + } + var cfg struct { + Backend string `yaml:"backend"` + } + if err := yaml.Unmarshal([]byte(resolved.ConfigFile), &cfg); err == nil { + return cfg.Backend + } + return "" +} + +// quantizationOf reports the quantization declared by a concrete entry's +// overrides, empty when it declares none. +func quantizationOf(m gallery.GalleryModel) string { + if q, ok := m.Overrides["quantization"].(string); ok { + return q + } + return "" +} diff --git a/.github/workflows/gallery-denormalize.yaml b/.github/workflows/gallery-denormalize.yaml new file mode 100644 index 000000000..14551d3b1 --- /dev/null +++ b/.github/workflows/gallery-denormalize.yaml @@ -0,0 +1,30 @@ +name: Denormalize gallery meta entries +on: + schedule: + - cron: 0 22 * * * + workflow_dispatch: +jobs: + denormalize: + if: github.repository == 'mudler/LocalAI' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Denormalize meta candidates + run: go run ./.github/ci/gallery_denormalize.go gallery/index.yaml + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.UPDATE_BOT_TOKEN }} + push-to-fork: ci-forks/LocalAI + commit-message: ':arrow_up: Denormalize meta model candidates' + title: 'chore(model-gallery): :arrow_up: refresh meta candidate metadata' + branch: "update/gallery-denormalize" + body: | + Refreshes the denormalized `backend`, `quantization` and + `inferred_min_vram` fields on meta model entries. + + Authored `min_vram` values are never modified. + signoff: true