feat(addons): move connector file bytes by handle

A connector add-on that declares files.transfer no longer carries file
bytes in the connector envelope. The host puts a random handle in the
request of the op media (write) or send_file (read), and the add-on
streams the file with two new host operations, connector.file.put and
connector.file.get, in chunks of at most 256 KiB.

The process that runs the add-on registers the handle when the call
arrives and ends it when the call returns. A handle is bound to the
add-on, so another add-on gets the same answer as for an unknown
handle. An unfinished inbound file is dropped with its partial bytes,
and a handle with no call for two minutes expires. Inbound bytes go
to the blob store of the workspace, outbound bytes come from it, so a
cluster needs the shared object store and no shared disk. The file is
never held in memory. The host overwrites the size and hash of an
inbound file with its own record.

The change is additive: the field files.transfer needs manifest API 2,
a kind without it keeps the inline path and its 1 MiB cap, and the host
never sends it a handle. A call that carries a handle may run up to 15
minutes. The ceiling of a file also stays under the largest file of a
workspace.

Assisted-by: Claude:claude-sonnet-5-5 go test
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 15:54:45 +00:00
1 parent f5f4b7ea58
commit 4ba1b7b2bd
21 files changed
+1453 -14

No files matched your search

+1 -1
View File
@@ -302,7 +302,7 @@ func (a *Application) startAddonGateway() error {
if mode == auth.AddonModeLocalOwner {
xlog.Warn(localOwnerWarning)
}
g := &gateway.Gateway{Limits: c.Limits, Manager: a.addonManager, ConnectorAuthority: connectorAuthority{a}, Revalidate: auth.AddonRevalidate(a.authDB, mode)}
g := &gateway.Gateway{Transfers: a.transfers(), Limits: c.Limits, Manager: a.addonManager, ConnectorAuthority: connectorAuthority{a}, Revalidate: auth.AddonRevalidate(a.authDB, mode)}
if cfg.InstanceID != cfg.OwnerID {
g.Forward = carrier
a.addonGateway = g
+10
View File
@@ -68,6 +68,16 @@ func (a *Application) studioToolPins(ctx context.Context) (map[string]string, er
// the owner policy. Without add-ons there is no owner policy and no connector
// to move a file, so the default stands.
func (a *Application) studioConnectorFilesCeiling(ctx context.Context) (int64, error) {
n, err := a.policyFilesCeiling(ctx)
if err != nil {
return 0, err
}
// A file that a sender shares becomes a file of the chat, so the ceiling
// never passes what one file of a workspace may be.
return min(n, a.studioUploadLimit()), nil
}
func (a *Application) policyFilesCeiling(ctx context.Context) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
+7 -3
View File
@@ -26,6 +26,7 @@ import (
"github.com/mudler/LocalAI/core/services/addons/backendlib"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/hostops"
"github.com/mudler/LocalAI/core/services/addons/hostsettings"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/core/services/addons/pins"
@@ -113,9 +114,12 @@ type Application struct {
backendLibrary *backendlib.Installer
addonManager *addons.Manager
addonHostOps *addonruntime.HostOps
addonDataRoot string // parent of the private data directory of each add-on; empty when none runs here
addonsProbe addonsProbeState
modelsProbe modelsProbeState
// addonTransfers holds the handles of connector file transfers; see transfers.
addonTransfers *hostops.Transfers
addonTransfersOnce sync.Once
addonDataRoot string // parent of the private data directory of each add-on; empty when none runs here
addonsProbe addonsProbeState
modelsProbe modelsProbeState
// libraryStore is the library sources file when no add-on owner runs here.
libraryStore *catalog.Sources
+25 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"time"
"github.com/mudler/xlog"
@@ -63,7 +64,16 @@ func (t connectorTransport) Call(ctx context.Context, scope studio.ConnectorCall
// function as a message the manager pulls itself. A dedicated add-on owner
// refuses it instead.
func (a *Application) RegisterAddonConnector() error {
if a.addonHostOps == nil || a.studioConnectors == nil {
if a.addonHostOps == nil {
return nil
}
// The two file operations serve every connector add-on that declares
// files.transfer; the handles come with the connector calls. They are
// registered wherever add-ons run, also on a dedicated owner.
if err := hostops.RegisterConnectorTransfer(a.addonHostOps.Broker, a.transfers()); err != nil {
return err
}
if a.studioConnectors == nil {
return nil
}
if a.ownsAddonsOnly() {
@@ -130,3 +140,17 @@ func (a *Application) startStudioConnectors(svc *studio.Service) {
a.studioConnectors, a.studioConnectorsStop = c, cancel
go c.Run(ctx)
}
// transfers is the table of live connector file handles of this process. The
// gateway checks and ends them; the broker serves the bytes. Its blob store is
// the one of the workspace, set when the files start.
func (a *Application) transfers() *hostops.Transfers {
a.addonTransfersOnce.Do(func() {
t := hostops.NewTransfers(nil)
t.Audit = func(o hostops.TransferOutcome) {
xlog.Info("Connector file transfer", "direction", o.Direction, "binding", o.BindingID, "bytes", o.Bytes, "result", o.Result, "duration", o.Duration.Round(time.Millisecond))
}
a.addonTransfers = t
})
return a.addonTransfers
}
+1
View File
@@ -108,6 +108,7 @@ func (a *Application) initStudioFilesWithS3(newS3 func(context.Context, storage.
if err != nil {
return stop("workspace blob store", err, "Files are off: their storage could not be opened.")
}
a.transfers().UseBlobs(blobs)
ws, closer, err := a.openWorkspaceIndex(root, blobs)
if err != nil {
return stop("workspace index", err, "Files are off: the workspace could not be started.")