feat(addons): let a connector kind declare its file abilities

A connector kind can declare files.receive and files.send, each with a
maximum size and optional media types. The field is additive and needs
manifest api 2. A kind that declares nothing keeps the inline path.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:47:50 +00:00
1 parent 45f7ff0b1c
commit 198d03125b
3 files changed
+174

No files matched your search

+7
View File
@@ -141,6 +141,10 @@ type ConnectorKind struct {
// this declaration and sends it with the op poll. It needs manifest api
// MinHistoryAPI.
Options []ConnectorOption `json:"options,omitempty"`
// Files declares the files the kind can receive and send, with a limit
// for each direction. Without it, the host moves files inline and caps
// them at MaxConnectorFileBytes. It needs manifest api MinFilesAPI.
Files *ConnectorFiles `json:"files,omitempty"`
}
// ConnectorOption is one choice among a few values.
@@ -285,6 +289,9 @@ func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error {
return err
}
}
if err := k.Files.validate(api); err != nil {
return err
}
if k.Page != "" {
ok := false
if ui != nil {
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
package contract
import (
"fmt"
"regexp"
)
// MinFilesAPI is the first manifest API version that knows the files ability
// of a connector kind. A manifest that declares one must accept it.
const MinFilesAPI uint64 = 2
// Limits of the files ability.
const (
// MaxDeclaredFileBytes is the largest limit a kind may declare: 1 TiB.
// The host ceiling and the owner's settings still apply.
MaxDeclaredFileBytes int64 = 1 << 40
// MaxFileTypes bounds the media types of one direction.
MaxFileTypes = 32
)
// FileAbility is what a connector kind can move in one direction.
type FileAbility struct {
// MaxBytes is the largest file the platform lets the add-on move, in
// bytes. The host uses it as an upper bound that the owner can lower. It
// is not proof that a transfer works.
MaxBytes int64 `json:"maxBytes"`
// Types lists the media types the kind moves, such as application/pdf or
// image/*. Empty means every type the host allows.
Types []string `json:"types,omitempty"`
}
// ConnectorFiles is the manifest field files of a connector kind. A kind that
// declares none keeps the inline path, capped by MaxConnectorFileBytes.
type ConnectorFiles struct {
// Receive is the ability to read files that people send to the bot.
Receive *FileAbility `json:"receive,omitempty"`
// Send is the ability to upload files from the bot.
Send *FileAbility `json:"send,omitempty"`
}
var mediaTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,63})$`)
func (a *FileAbility) validate(dir string) error {
if a == nil {
return nil
}
if a.MaxBytes < 1 || a.MaxBytes > MaxDeclaredFileBytes {
return fmt.Errorf("files.%s.maxBytes must be 1 to %d", dir, MaxDeclaredFileBytes)
}
if len(a.Types) > MaxFileTypes {
return fmt.Errorf("files.%s.types has more than %d entries", dir, MaxFileTypes)
}
seen := map[string]bool{}
for _, t := range a.Types {
if !mediaTypePattern.MatchString(t) || seen[t] {
return fmt.Errorf("files.%s.types needs distinct media types such as application/pdf or image/*", dir)
}
seen[t] = true
}
return nil
}
func (f *ConnectorFiles) validate(api VersionRange) error {
if f == nil {
return nil
}
if api.Max < MinFilesAPI {
return fmt.Errorf("a connector kind that declares files needs api %d", MinFilesAPI)
}
if f.Receive == nil && f.Send == nil {
return fmt.Errorf("files must declare receive, send or both")
}
if err := f.Receive.validate("receive"); err != nil {
return err
}
return f.Send.validate("send")
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MIT
package contract
import (
"encoding/json"
"os"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the files ability of a connector kind", func() {
var m Manifest
BeforeEach(func() {
b, err := os.ReadFile("../../../tests/addons/fixtures/contracts/page.json")
Expect(err).NotTo(HaveOccurred())
m, err = ParseManifest(b)
Expect(err).NotTo(HaveOccurred())
m.API = VersionRange{Min: 1, Max: 2}
m.Capabilities.UI = nil
m.Capabilities.Connector = &ConnectorCapability{Kinds: []ConnectorKind{{ID: "telegram", Title: "Telegram", Delivery: DeliveryPull}}}
})
host := func() HostCompatibility {
h := compatible()
h.API, h.APIMin = HostAPIVersion, HostAPIMin
h.SDK = HostSDKVersion
return h
}
declare := func(f *ConnectorFiles) { m.Capabilities.Connector.Kinds[0].Files = f }
It("accepts a kind that declares what it receives and sends", func() {
declare(&ConnectorFiles{
Receive: &FileAbility{MaxBytes: 20 << 20},
Send: &FileAbility{MaxBytes: 50 << 20, Types: []string{"application/pdf", "image/*"}},
})
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("accepts a kind that declares one direction only, or nothing", func() {
declare(&ConnectorFiles{Send: &FileAbility{MaxBytes: 1 << 20}})
Expect(ValidateManifest(m, host())).To(Succeed())
declare(nil)
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("needs a manifest that accepts API 2", func() {
declare(&ConnectorFiles{Receive: &FileAbility{MaxBytes: 1 << 20}})
m.API = VersionRange{Min: 1, Max: 1}
Expect(ValidateManifest(m, host())).To(MatchError(ContainSubstring("api 2")))
})
DescribeTable("refuses",
func(f *ConnectorFiles) {
declare(f)
Expect(ValidateManifest(m, host())).NotTo(Succeed())
},
Entry("a limit of zero", &ConnectorFiles{Receive: &FileAbility{}}),
Entry("a negative limit", &ConnectorFiles{Send: &FileAbility{MaxBytes: -1}}),
Entry("a limit above the largest the host knows", &ConnectorFiles{Send: &FileAbility{MaxBytes: MaxDeclaredFileBytes + 1}}),
Entry("an empty ability", &ConnectorFiles{}),
Entry("a type that is no media type", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: []string{"pdf"}}}),
Entry("a repeated type", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: []string{"image/*", "image/*"}}}),
Entry("too many types", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: manyTypes(MaxFileTypes + 1)}}),
)
It("reads files from JSON and refuses an unknown field in it", func() {
var k ConnectorKind
Expect(json.Unmarshal([]byte(`{"id":"x","title":"X","delivery":"pull","files":{"receive":{"maxBytes":10,"types":["text/plain"]}}}`), &k)).To(Succeed())
Expect(k.Files.Receive.MaxBytes).To(Equal(int64(10)))
Expect(k.Files.Send).To(BeNil())
declare(&ConnectorFiles{Receive: &FileAbility{MaxBytes: 1}})
raw, err := json.Marshal(m)
Expect(err).NotTo(HaveOccurred())
_, err = ParseManifest(raw)
Expect(err).NotTo(HaveOccurred())
bad := strings.Replace(string(raw), `"maxBytes":1`, `"maxBytes":1,"bogus":2`, 1)
_, err = ParseManifest([]byte(bad))
Expect(err).To(HaveOccurred())
})
})
func manyTypes(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = "text/x-" + strings.Repeat("a", i+1)
}
return out
}