From 474e3eefbed0a8fcf92a7a095967b8c1003a8833 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:39:10 +0000 Subject: [PATCH] refactor(addons): pick engine job backends by gallery tag The finetune.start and quantize.start operations defaulted to the backend names "trl" and "llama-cpp-quantization", and the backend lister had one method per engine family. Both were special cases that a new engine could not use. The lister now answers for a gallery tag. A start call that names no backend gets the first installed backend of the tag in gallery order, and a named backend must carry the tag. With no engine installed the call fails with a 400 that says so. The shipped gallery order keeps trl and llama-cpp-quantization as the defaults, and a test pins it. The change is additive for add-ons: no manifest field or operation changes. Assisted-by: Claude:claude-sonnet-5-5 go gofmt --- core/gallery/backends_test.go | 26 ++++++++++ .../endpoints/localai/addon_engine_jobs.go | 20 ++++---- core/services/addons/hostops/engine_jobs.go | 46 +++++++++++------ .../addons/hostops/engine_jobs_test.go | 30 +++++++++--- .../addons/hostops/engine_tags_test.go | 49 +++++++++++++++++++ docs/content/features/addons.md | 5 ++ 6 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 core/services/addons/hostops/engine_tags_test.go diff --git a/core/gallery/backends_test.go b/core/gallery/backends_test.go index f508651731..0162ad6dd1 100644 --- a/core/gallery/backends_test.go +++ b/core/gallery/backends_test.go @@ -1663,3 +1663,29 @@ var _ = Describe("installed development backend preference root guards", func() Expect(found).To(HaveKey("cpu-example-engine-development")) }) }) + +var _ = Describe("the shipped backend index", func() { + // An add-on job that names no backend gets the first backend of its tag in + // gallery order, so the order of these entries is the default. + It("lists the default engine of each family first", func() { + raw, err := os.ReadFile("../../backend/index.yaml") + Expect(err).ToNot(HaveOccurred()) + var all []GalleryBackend + Expect(yaml.Unmarshal(raw, &all)).To(Succeed()) + first := func(tag string) string { + for _, b := range all { + for _, t := range b.Tags { + if t == tag { + if b.Alias != "" { + return b.Alias + } + return b.Name + } + } + } + return "" + } + Expect(first("fine-tuning")).To(Equal("trl")) + Expect(first("quantization")).To(Equal("llama-cpp-quantization")) + }) +}) diff --git a/core/http/endpoints/localai/addon_engine_jobs.go b/core/http/endpoints/localai/addon_engine_jobs.go index b12d98b1fe..5abff7a0c6 100644 --- a/core/http/endpoints/localai/addon_engine_jobs.go +++ b/core/http/endpoints/localai/addon_engine_jobs.go @@ -71,11 +71,17 @@ type EngineBackends struct { var _ hostops.BackendLister = EngineBackends{} -func (e EngineBackends) FineTune() []string { return e.names("fine-tuning") } +// Backends lists the installed backends that carry tag, in gallery order. +func (e EngineBackends) Backends(tag string) []string { + infos := e.lookup(tag) + out := make([]string, 0, len(infos)) + for _, i := range infos { + out = append(out, i.Name) + } + return out +} -func (e EngineBackends) Quantization() []string { return e.names("quantization") } - -func (e EngineBackends) names(tag string) []string { +func (e EngineBackends) lookup(tag string) []engineBackendInfo { // The interface has no request context, so the lookup gets its own bound. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -83,11 +89,7 @@ func (e EngineBackends) names(tag string) []string { if err != nil { return nil } - out := make([]string, 0, len(infos)) - for _, i := range infos { - out = append(out, i.Name) - } - return out + return infos } // FineTuneJobs adapts the fine-tuning service to the add-on host operations. diff --git a/core/services/addons/hostops/engine_jobs.go b/core/services/addons/hostops/engine_jobs.go index 3c191ef541..64246569ed 100644 --- a/core/services/addons/hostops/engine_jobs.go +++ b/core/services/addons/hostops/engine_jobs.go @@ -42,11 +42,32 @@ type Quantizer interface { List(userID string) []*schema.QuantizationJob } -// BackendLister names the backends of each engine feature that the gallery -// offers. A start operation accepts only these. +// Gallery tags that name an engine family. A backend carries the tag in its +// gallery entry; the host reads the engines of a family from there, so a new +// engine needs a gallery entry and no code. +const ( + TagFineTuning = "fine-tuning" + TagQuantization = "quantization" +) + +// BackendLister names the installed backends that carry a gallery tag, in +// gallery order. A start operation accepts only these. type BackendLister interface { - FineTune() []string - Quantization() []string + Backends(tag string) []string +} + +// pickEngine answers the engine for a start call. A named engine is kept for +// requireEngine to check. Without a name the default is the first installed +// engine of the tag in gallery order, so the gallery picks the default by the +// order of its entries; "" means none is installed. +func pickEngine(l BackendLister, tag, named string) string { + if named != "" { + return named + } + if all := l.Backends(tag); len(all) > 0 { + return all[0] + } + return "" } // Checkpoint is what the operations need of a saved checkpoint. The path is @@ -115,11 +136,8 @@ func fineTuneSpecs(b *Broker, ft FineTuner, backends BackendLister) []Spec { if a.LearningRate < 0 || a.NumEpochs < 0 || a.BatchSize < 0 || a.MaxSteps < 0 || a.AdapterRank < 0 || a.AdapterAlpha < 0 { return nil, fmt.Errorf("%w: numeric arguments must not be negative", ErrBadRequest) } - backend := a.Backend - if backend == "" { - backend = "trl" - } - if err := requireEngine("fine-tuning", backend, backends.FineTune()); err != nil { + backend := pickEngine(backends, TagFineTuning, a.Backend) + if err := requireEngine("fine-tuning", backend, backends.Backends(TagFineTuning)); err != nil { return nil, err } resp, err := ft.Start(ctx, userKey(c), schema.FineTuneJobRequest{ @@ -198,11 +216,8 @@ func quantizeSpecs(q Quantizer, backends BackendLister) []Spec { if a.QuantizationType != "" && !quantType.MatchString(a.QuantizationType) { return nil, fmt.Errorf("%w: quantizationType is not a quantization name", ErrBadRequest) } - backend := a.Backend - if backend == "" { - backend = "llama-cpp-quantization" - } - if err := requireEngine("quantization", backend, backends.Quantization()); err != nil { + backend := pickEngine(backends, TagQuantization, a.Backend) + if err := requireEngine("quantization", backend, backends.Backends(TagQuantization)); err != nil { return nil, err } resp, err := q.Start(ctx, userKey(c), schema.QuantizationJobRequest{ @@ -319,6 +334,9 @@ var engineName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) // the engine, in a short fixed sentence that the add-on can pass on to the // person, so a missing engine is not mistaken for a bad argument. func requireEngine(kind, backend string, installed []string) error { + if backend == "" { + return fmt.Errorf("%w: no %s engine is installed", ErrBadRequest, kind) + } if !engineName.MatchString(backend) { return refusedField("backend") } diff --git a/core/services/addons/hostops/engine_jobs_test.go b/core/services/addons/hostops/engine_jobs_test.go index 1c4f718b09..8515983cb5 100644 --- a/core/services/addons/hostops/engine_jobs_test.go +++ b/core/services/addons/hostops/engine_jobs_test.go @@ -74,8 +74,15 @@ func (q *fakeQ) List(user string) []*schema.QuantizationJob { type backends struct{} -func (backends) FineTune() []string { return []string{"trl"} } -func (backends) Quantization() []string { return []string{"llama-cpp-quantization"} } +func (backends) Backends(tag string) []string { + switch tag { + case TagFineTuning: + return []string{"trl"} + case TagQuantization: + return []string{"llama-cpp-quantization"} + } + return nil +} func call(spec Spec, mode, user, args string) (json.RawMessage, error) { return spec.Handler(context.Background(), Call{AddonID: "fine-tuning", OperationID: "op1", Actor: contract.Actor{UserID: user, Mode: mode}, Arguments: json.RawMessage(args)}) @@ -87,6 +94,13 @@ func register(ft FineTuner, q Quantizer) map[string]Spec { return b.specs } +// registerWith is register over a given backend lister. +func registerWith(ft FineTuner, q Quantizer, l BackendLister) map[string]Spec { + b := New(NewInFlight(), func(string, string) bool { return true }, contract.DefaultLimits()) + Expect(RegisterEngineJobs(b, ft, q, l)).To(Succeed()) + return b.specs +} + // registerWithout is register with a host that has no engine installed. func registerWithout(ft FineTuner, q Quantizer) map[string]Spec { b := New(NewInFlight(), func(string, string) bool { return true }, contract.DefaultLimits()) @@ -96,8 +110,12 @@ func registerWithout(ft FineTuner, q Quantizer) map[string]Spec { type noBackends struct{} -func (noBackends) FineTune() []string { return nil } -func (noBackends) Quantization() []string { return nil } +func (noBackends) Backends(string) []string { return nil } + +// tagged is a lister over a fixed map of engines per tag. +type tagged struct{ engines map[string][]string } + +func (t tagged) Backends(tag string) []string { return t.engines[tag] } func specs(ft FineTuner) map[string]Spec { return register(ft, nil) @@ -162,7 +180,7 @@ var _ = Describe("Fine-tuning and quantization job operations", func() { s := registerWithout(&fakeFT{}, nil) _, err := call(s["finetune.start"], "named-user", "u1", `{"model":"org/base","datasetSource":"org/data"}`) Expect(err).To(MatchError(ErrBadRequest)) - Expect(err.Error()).To(ContainSubstring("fine-tuning engine trl is not installed")) + Expect(err.Error()).To(ContainSubstring("no fine-tuning engine is installed")) _, err = call(s["finetune.start"], "named-user", "u1", `{"model":"org/base","datasetSource":"org/data","backend":"../x"}`) Expect(err).To(MatchError(ErrBadRequest)) Expect(err.Error()).NotTo(ContainSubstring("..")) @@ -261,7 +279,7 @@ var _ = Describe("Fine-tuning and quantization job operations", func() { s := registerWithout(nil, &fakeQ{}) _, err := call(s["quantize.start"], "named-user", "u1", `{"model":"org/base"}`) Expect(err).To(MatchError(ErrBadRequest)) - Expect(err.Error()).To(ContainSubstring("quantization engine llama-cpp-quantization is not installed")) + Expect(err.Error()).To(ContainSubstring("no quantization engine is installed")) }) It("start refuses a field that is not in the tool schema", func() { diff --git a/core/services/addons/hostops/engine_tags_test.go b/core/services/addons/hostops/engine_tags_test.go new file mode 100644 index 0000000000..affbeccf35 --- /dev/null +++ b/core/services/addons/hostops/engine_tags_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +package hostops + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The engine of a job comes from the gallery tag, not from a name in the host. +var _ = Describe("engine selection by gallery tag", func() { + start := func(l BackendLister, ft *fakeFT, q *fakeQ, op, args string) error { + b := registerWith(ft, q, l) + _, err := call(b[op], "named-user", "u1", args) + return err + } + + It("uses the first engine of the tag in gallery order when no backend is named", func() { + ft, q := &fakeFT{}, &fakeQ{} + l := tagged{engines: map[string][]string{TagFineTuning: {"beta", "alpha"}, TagQuantization: {"delta", "gamma"}}} + Expect(start(l, ft, q, "finetune.start", `{"model":"org/base","datasetSource":"org/data"}`)).To(Succeed()) + Expect(start(l, ft, q, "quantize.start", `{"model":"org/base"}`)).To(Succeed()) + Expect(ft.started[0].Backend).To(Equal("beta")) + Expect(q.started[0].Backend).To(Equal("delta")) + }) + + It("accepts a named engine that carries the tag, and refuses one that does not", func() { + ft := &fakeFT{} + l := tagged{engines: map[string][]string{TagFineTuning: {"alpha", "beta"}}} + Expect(start(l, ft, &fakeQ{}, "finetune.start", `{"model":"org/base","datasetSource":"org/data","backend":"beta"}`)).To(Succeed()) + Expect(ft.started[0].Backend).To(Equal("beta")) + err := start(l, ft, &fakeQ{}, "finetune.start", `{"model":"org/base","datasetSource":"org/data","backend":"trl"}`) + Expect(err).To(MatchError(ErrBadRequest)) + Expect(err.Error()).To(ContainSubstring("fine-tuning engine trl is not installed")) + }) + + It("refuses a start with no backend when none is installed", func() { + ft := &fakeFT{} + err := start(tagged{}, ft, &fakeQ{}, "finetune.start", `{"model":"org/base","datasetSource":"org/data"}`) + Expect(err).To(MatchError(ErrBadRequest)) + Expect(err.Error()).To(ContainSubstring("no fine-tuning engine is installed")) + Expect(ft.started).To(BeEmpty()) + }) + + It("does not mix the families", func() { + l := tagged{engines: map[string][]string{TagFineTuning: {"alpha"}, TagQuantization: {"gamma"}}} + err := start(l, &fakeFT{}, &fakeQ{}, "quantize.start", `{"model":"org/base","backend":"alpha"}`) + Expect(err).To(MatchError(ErrBadRequest)) + }) +}) diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 7718b5639d..7da719650d 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -432,6 +432,11 @@ The generic binary selects no policy automatically. Fine-tuning and Quantization include operation names passed through mutation and undo wrappers. They do not request the host's unused progress operations. + +The `finetune.start` and `quantize.start` operations take an optional `backend`. +The host accepts only an installed backend whose gallery entry carries the tag of the operation (`fine-tuning` or `quantization`). +When the call names no backend, the host uses the first installed backend of that tag in gallery order. +A new engine needs only a gallery entry with the tag. With no engine installed, the call fails with a `400` that says so. Telegram's historical `connector.` deployment grant does not imply a hostclient call or manufacture a manifest request. Sandbox's proxy uses the optional egress operations when configured connections need policy decisions.