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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-18 11:18:23 +00:00
parent 2794b31865
commit 9ed4f6a076
2 changed files with 288 additions and 0 deletions

258
.github/ci/gallery_denormalize.go vendored Normal file
View File

@@ -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 <index.yaml>")
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 ""
}

View File

@@ -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