feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 21:40:02 +00:00
1 parent 898357d74c
commit 3569da24c5
6 files changed
+262 -2

No files matched your search

+30
View File
@@ -90,6 +90,36 @@ var _ = Describe("addon bootstrap", func() {
Expect(e).NotTo(HaveOccurred())
})
It("registers the official library once and respects the admin afterwards", func() {
requireAddonRuntime()
c := cfg()
c.OfficialLibrary = true
Expect(os.MkdirAll(c.Root, 0700)).To(Succeed())
// The default library is on the network; keep this spec offline by
// cancelling the startup refresh. The seed happens before it.
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := setup()
a.applicationConfig.Addons = c
a.applicationConfig.Context = ctx
_ = a.startAddons()
sources, e := catalog.NewSources(filepath.Join(c.Root, "sources.json"), c.AdminCheck, catalog.CredentialResolver{})
Expect(e).NotTo(HaveOccurred())
list, e := sources.List(context.Background(), contract.Actor{})
Expect(e).NotTo(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].URL).To(Equal(catalog.OfficialLibraryURL))
// The admin removes it; the next start does not add it again.
Expect(sources.Save(context.Background(), contract.Actor{}, catalog.Source{ID: catalog.OfficialLibraryID, Removed: true})).To(Succeed())
a = setup()
a.applicationConfig.Addons = c
Expect(a.startAddons()).To(Succeed())
DeferCleanup(a.Shutdown)
list, e = a.AddonSources().List(context.Background(), contract.Actor{})
Expect(e).NotTo(HaveOccurred())
Expect(list).To(BeEmpty())
})
It("bounds offline refresh and ignores disabled sources and forwarders", func() {
requireAddonRuntime()
var calls atomic.Int32
+5
View File
@@ -867,6 +867,11 @@ func (a *Application) startAddons() error {
if err != nil {
return err
}
if c.OfficialLibrary {
if _, err = sources.SeedDefault(catalog.OfficialLibrary()); err != nil {
return err
}
}
host := c.Host
if host == (contract.HostCompatibility{}) {
host = contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
package catalog
const (
// OfficialLibraryID is the source ID of the official add-on library.
OfficialLibraryID = "official"
// OfficialLibraryURL is the YAML index of the first-party add-ons, kept in
// this repository at gallery/addons/index.yaml and served from the master
// branch, like the model gallery.
OfficialLibraryURL = "https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/addons/index.yaml"
)
// OfficialLibrary is the library a standard install registers on its first
// start. It requires the signature of the add-on package workflow that runs on
// master, so a package that master did not build and sign is refused.
func OfficialLibrary() Source {
return Source{
ID: OfficialLibraryID,
Name: "Official add-ons",
URL: OfficialLibraryURL,
Enabled: true,
Policy: VerificationPolicy{
RequireSignature: true,
Issuer: "https://token.actions.githubusercontent.com",
Identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master",
},
}
}
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
package catalog
import (
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("official library default", func() {
It("is a valid, signature-requiring source", func() {
src := OfficialLibrary()
Expect(validSource(src)).To(BeTrue())
Expect(src.Enabled).To(BeTrue())
Expect(src.Policy.RequireSignature).To(BeTrue())
Expect(src.URL).To(HavePrefix("https://raw.githubusercontent.com/mudler/LocalAI/master/"))
})
It("is added when there is no sources.json and is written to it", func() {
s := setup()
added, e := s.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
Expect(added).To(BeTrue())
list, e := s.List(ctx, admin)
Expect(e).NotTo(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].ID).To(Equal(OfficialLibraryID))
info, e := os.Stat(s.path)
Expect(e).NotTo(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
reloaded, e := NewSources(s.path, checker, CredentialResolver{})
Expect(e).NotTo(HaveOccurred())
Expect(reloaded.sources).To(HaveKey(OfficialLibraryID))
})
It("keeps a sources.json the admin already has", func() {
s := setup()
save(s, "mine", "https://example.org/index.yaml")
reloaded, e := NewSources(s.path, checker, CredentialResolver{})
Expect(e).NotTo(HaveOccurred())
added, e := reloaded.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
Expect(added).To(BeFalse())
Expect(reloaded.sources).To(HaveLen(1))
Expect(reloaded.sources).To(HaveKey("mine"))
})
It("does not add the default again after the admin removed it", func() {
s := setup()
_, e := s.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
Expect(s.Save(ctx, admin, Source{ID: OfficialLibraryID, Removed: true})).To(Succeed())
reloaded, e := NewSources(s.path, checker, CredentialResolver{})
Expect(e).NotTo(HaveOccurred())
added, e := reloaded.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
Expect(added).To(BeFalse())
Expect(reloaded.sources).To(BeEmpty())
})
It("seeds only once per instance", func() {
s := setup()
_, e := s.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
added, e := s.SeedDefault(OfficialLibrary())
Expect(e).NotTo(HaveOccurred())
Expect(added).To(BeFalse())
})
})
var _ = Describe("official library index", func() {
It("lists the four first-party add-ons in the library format", func() {
data, e := os.ReadFile("../../../../gallery/addons/index.yaml")
Expect(e).NotTo(HaveOccurred())
releases, e := DecodeIndex(data)
Expect(e).NotTo(HaveOccurred())
ids := []string{}
for _, r := range releases {
ids = append(ids, r.AddonID)
Expect(digestPattern.MatchString(r.Digest)).To(BeTrue(), r.AddonID)
Expect(versionPattern.MatchString(r.Version)).To(BeTrue(), r.AddonID)
Expect(r.Artifact).To(HavePrefix("oci://"), r.AddonID)
}
Expect(ids).To(ConsistOf("fine-tuning", "quantization", "voice", "face"))
})
})
+39 -2
View File
@@ -77,6 +77,10 @@ type Sources struct {
credentials CredentialResolver
sources map[string]Source
cache map[string]cached
// fresh is true when no configuration file existed at construction. Only
// then may SeedDefault add a library; a file the admin already has, even an
// empty one, is never touched.
fresh bool
}
var idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
@@ -91,6 +95,7 @@ func NewSources(path string, admin AdminCheck, credentials CredentialResolver) (
s := &Sources{path: path, admin: admin, credentials: credentials, sources: map[string]Source{}, cache: map[string]cached{}}
info, e := os.Lstat(path)
if os.IsNotExist(e) {
s.fresh = true
return s, nil
}
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
@@ -212,6 +217,16 @@ func (s *Sources) Save(ctx context.Context, actor contract.Actor, src Source) er
} else {
next[src.ID] = src
}
if e := s.persist(next); e != nil {
return e
}
s.sources = next
delete(s.cache, src.ID)
return nil
}
// persist writes the whole configuration atomically. The caller holds s.mu.
func (s *Sources) persist(next map[string]Source) error {
data, e := json.Marshal(next)
if e != nil || len(data) > 1<<20 {
return ErrInvalid
@@ -231,10 +246,32 @@ func (s *Sources) Save(ctx context.Context, actor contract.Actor, src Source) er
if e = os.Rename(f.Name(), s.path); e != nil {
return ErrUnavailable
}
s.sources = next
delete(s.cache, src.ID)
return nil
}
// SeedDefault adds a default library on the first start, when the owner has no
// configuration file yet, and writes the file. The file is the record: once it
// exists, the admin's choices win, so a default that the admin removes stays
// removed (the file then holds the remaining libraries, possibly none) and a
// library the admin edited is not reset. It reports whether it added src.
// It is trusted startup code and takes no actor; it never runs for a request.
func (s *Sources) SeedDefault(src Source) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.fresh {
return false, nil
}
if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) {
return false, ErrInvalid
}
next := map[string]Source{src.ID: src}
if e := s.persist(next); e != nil {
return false, e
}
s.sources = next
s.fresh = false
return true, nil
}
func decode(data []byte, out any) error {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
+77
View File
@@ -0,0 +1,77 @@
# Official add-on library: the first-party add-ons, one entry for each package.
# LocalAI registers this file as its default library (the raw URL of this file
# on the master branch). The entries are rendered by the add-on package
# workflow with `addonpkg catalog`, so the digests pin the published images:
# regenerate this file from the workflow output, never edit a digest by hand.
- addonId: "fine-tuning"
name: Fine-tuning
description: "Train an adapter on your own data, then export it as a model you can chat with."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/fine-tuning:sha-99d1dca2c869"
digest: "sha256:9e2168ea73c32427e9f7d3c071d2dd36ca37877a78dd3bdbacebd8b9fe6e270a"
os: "linux"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "Fine-tuning"
description: "Train an adapter on your own data, then export it as a model you can chat with."
mainPage: "jobs"
icon: "ui/icon.png"
- addonId: "quantization"
name: Quantization
description: "Shrink a model to a smaller quantized file, then import it so you can chat with it."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/quantization:sha-99d1dca2c869"
digest: "sha256:d1dd5b3cfc7e96cac2825cd8aaf86ebacb49d686f433f1969ad149928d0858ad"
os: "linux"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "Quantization"
description: "Shrink a model to a smaller quantized file, then import it so you can chat with it."
mainPage: "jobs"
icon: "ui/icon.png"
- addonId: "voice"
name: Voice
description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/voice:sha-99d1dca2c869"
digest: "sha256:1d9ff8c81ad8963c2a32426a97dfb4d2935833f9a36615af9c132040672236a2"
os: "linux"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "Voice"
description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them."
mainPage: "recognition"
icon: "ui/icon.png"
- addonId: "face"
name: Face
description: "Find out whose face is in a photo, or whether two photos show the same person."
version: "0.0.0-dev.99d1dca2c869"
channel: "development"
artifact: "oci://quay.io/go-skynet/local-ai-addons/face:sha-99d1dca2c869"
digest: "sha256:a48ad28afc98b10f952e330243487a0fd3ab2aa4187478e8768712f6b77e86f6"
os: "linux"
arch: "amd64"
policy:
requireSignature: true
issuer: "https://token.actions.githubusercontent.com"
identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master"
ui:
title: "Face"
description: "Find out whose face is in a photo, or whether two photos show the same person."
mainPage: "recognition"
icon: "ui/icon.png"