mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
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:
6 files changed
+262
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user