diff --git a/core/services/studio/attachfile.go b/core/services/studio/attachfile.go index 99c6d88c07..2a6142e0c7 100644 --- a/core/services/studio/attachfile.go +++ b/core/services/studio/attachfile.go @@ -8,6 +8,7 @@ import ( "path" "slices" + "github.com/mudler/LocalAI/core/services/studio/connectors" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/workspace" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -38,7 +39,7 @@ func (s *Service) attachTools(ctx context.Context, x session.Access, rec session return []session.Tool{{ Name: attachFileName, Description: attachFileDescription, Schema: json.RawMessage(attachFileSchema), Run: func(ctx context.Context, raw json.RawMessage) (string, error) { - return c.attachFile(ctx, x, raw) + return c.attachFile(ctx, x, b, raw) }, }} } @@ -47,7 +48,7 @@ func (s *Service) attachTools(ctx context.Context, x session.Access, rec session // sender, and marks it for the answer. The check is repeated when the file is // sent, because the file or the access can change in between; this one lets // the model tell the person at once why a file cannot go. -func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json.RawMessage) (string, error) { +func (c *Connectors) attachFile(ctx context.Context, x session.Access, b connectors.Binding, raw json.RawMessage) (string, error) { in, err := session.DecodeArgs[struct{ Path string }](raw) if err != nil { return "", err @@ -61,6 +62,7 @@ func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json. return "", notFound(err, in.Path) } name := contract.SafeFileName(n.Name) + limits := c.FileLimits(ctx, b) switch { case n.Kind == workspace.KindDir: return "", fmt.Errorf("%s is a folder, not a file; attach the files in it one by one", in.Path) @@ -68,8 +70,8 @@ func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json. return "", fmt.Errorf("%s is not sent: files of this type (%s) are not sent here. Documents, data, pictures, sound, video and zip files are", name, path.Ext(name)) case n.Size == 0: return "", fmt.Errorf("%s is empty, so there is nothing to send", name) - case n.Size > contract.MaxConnectorFileBytes: - return "", fmt.Errorf("%s is too large to send here (%d bytes; the limit is %d MB). Tell the person; offer to split it, to make a smaller file, or to paste the content", name, n.Size, contract.MaxConnectorFileBytes>>20) + case n.Size > limits.Send.Bytes: + return "", refuseAttachTooBig(name, n.Size, limits) } key := key(x.Actor().Principal, x.Actor().ChatID) c.amu.Lock() diff --git a/core/services/studio/connectorfiles.go b/core/services/studio/connectorfiles.go index 360854f233..0f9d9b7ffe 100644 --- a/core/services/studio/connectorfiles.go +++ b/core/services/studio/connectorfiles.go @@ -136,6 +136,7 @@ func (c *Connectors) sendFiles(b connectors.Binding, token string, m contract.Co return } files := c.outgoing(ctx, b, x, chatID, turn, since) + limits := c.FileLimits(ctx, b) var notes []string for i, f := range files { if i == maxFilesPerTurn { @@ -151,12 +152,12 @@ func (c *Connectors) sendFiles(b connectors.Binding, token string, m contract.Co } continue } - data, err := c.svc.readChatFile(ctx, x, f.path, contract.MaxConnectorFileBytes) + data, err := c.svc.readChatFile(ctx, x, f.path, limits.Send.Bytes) switch { case errors.Is(err, workspace.ErrNotFound): continue // gone since, or outside the chat's access: not named case errors.Is(err, errFileTooLarge): - notes = append(notes, fmt.Sprintf("%s is too large to send here (the limit is 1 MB). It is in the chat's files.", f.name)) + notes = append(notes, noteSendTooBig(f.name, limits)) continue case err != nil: c.log.Warn("studio: could not read a file for a connector", "binding", b.ID, "error", err) diff --git a/core/services/studio/connectorlimits.go b/core/services/studio/connectorlimits.go new file mode 100644 index 0000000000..593cbef622 --- /dev/null +++ b/core/services/studio/connectorlimits.go @@ -0,0 +1,215 @@ +package studio + +import ( + "cmp" + "context" + "fmt" + "strconv" + "strings" + + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// FileLayer names the layer that sets a file limit. The limit in effect is the +// smallest of the layers, and the sentences to people name the one that won. +type FileLayer string + +const ( + // LayerOwner is the limit that the owner set for one binding. + LayerOwner FileLayer = "owner" + // LayerAddon is the ability that the connector kind declares. + LayerAddon FileLayer = "addon" + // LayerHost is the host ceiling of the owner policy. + LayerHost FileLayer = "host" + // LayerInline is the cap of the inline path, which carries the bytes in + // the connector envelope. It applies until the bytes travel by + // reference, and to every add-on that declares no ability. + LayerInline FileLayer = "inline" +) + +// FileLimit is the largest file that may move in one direction, and the +// layer that sets it. +type FileLimit struct { + Bytes int64 + Layer FileLayer +} + +// EffectiveFileLimit is the one place that decides how large a file may be. +// It takes the smallest of the host ceiling (zero: the default), the ability +// the kind declares (nil: none), the owner's limit for the binding (zero: +// none) and the cap of the inline path. On a tie the layer that a person can +// change first is named: the owner, the add-on, the host, then the inline cap. +func EffectiveFileLimit(ceiling int64, declared *contract.FileAbility, owner int64) FileLimit { + if ceiling <= 0 { + ceiling = contract.DefaultFileCeilingBytes + } + limit := FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline} + for _, c := range []FileLimit{{ceiling, LayerHost}, abilityLimit(declared), {owner, LayerOwner}} { + if c.Bytes > 0 && c.Bytes <= limit.Bytes { + limit = c + } + } + return limit +} + +func abilityLimit(a *contract.FileAbility) FileLimit { + if a == nil { + return FileLimit{} + } + return FileLimit{Bytes: a.MaxBytes, Layer: LayerAddon} +} + +// FileLimits are the limits of one binding. +type FileLimits struct { + Receive, Send FileLimit + // ReceiveMax and SendMax are the most an owner may set: the ability of + // the kind (the inline cap, if it declares none) under the host ceiling. + ReceiveMax, SendMax int64 + // KindTitle is the name of the platform, for the sentences. + KindTitle string +} + +// fileCeiling reads the host ceiling of the owner policy. A failure reads as +// the default: the layers below still bind, and the ceiling never lifts them. +func (c *Connectors) fileCeiling(ctx context.Context) int64 { + if c.o.FileCeiling == nil { + return contract.DefaultFileCeilingBytes + } + n, err := c.o.FileCeiling(ctx) + if err != nil || n <= 0 { + if err != nil { + c.log.Warn("studio: the host ceiling of connector files is not readable", "error", err) + } + return contract.DefaultFileCeilingBytes + } + return n +} + +// FileLimits resolves the limits of a binding now. The kind is read from the +// add-ons that run; a binding whose add-on does not run has declared nothing. +func (c *Connectors) FileLimits(ctx context.Context, b connectors.Binding) FileLimits { + var files *contract.ConnectorFiles + title := "" + if kinds, err := c.o.Transport.Kinds(ctx); err == nil { + if k, ok := kindFor(kinds, b); ok { + files, title = k.Files, k.Title + } + } + return resolveFileLimits(c.fileCeiling(ctx), files, title, b) +} + +func resolveFileLimits(ceiling int64, files *contract.ConnectorFiles, title string, b connectors.Binding) FileLimits { + if files == nil { + files = &contract.ConnectorFiles{} + } + l := FileLimits{KindTitle: title} + l.Receive = EffectiveFileLimit(ceiling, files.Receive, b.FileReceiveMax) + l.Send = EffectiveFileLimit(ceiling, files.Send, b.FileSendMax) + l.ReceiveMax = ownerMax(ceiling, files.Receive) + l.SendMax = ownerMax(ceiling, files.Send) + return l +} + +// ownerMax is the largest value an owner may set: what the kind declares, or +// the inline cap when it declares nothing, under the host ceiling. +func ownerMax(ceiling int64, a *contract.FileAbility) int64 { + if ceiling <= 0 { + ceiling = contract.DefaultFileCeilingBytes + } + base := int64(contract.MaxConnectorFileBytes) + if a != nil { + base = a.MaxBytes + } + return min(base, ceiling) +} + +// checkOwnerLimits refuses a limit above what the kind allows: an owner can +// only lower it. A kind that does not run has no limit to compare with. +func (c *Connectors) checkOwnerLimits(ctx context.Context, b connectors.Binding, in ConnectorInput) error { + // Only a value that this request writes is checked: a stored value that + // the kind has lowered since must not block an unrelated update. + if (in.FileReceiveMax == nil || *in.FileReceiveMax == 0) && (in.FileSendMax == nil || *in.FileSendMax == 0) { + return nil + } + kinds, err := c.o.Transport.Kinds(ctx) + if err != nil { + return nil + } + k, ok := kindFor(kinds, b) + if !ok { + return nil + } + l := resolveFileLimits(c.fileCeiling(ctx), k.Files, k.Title, b) + for _, f := range []struct { + what string + set, allowed int64 + }{{"receive", b.FileReceiveMax, l.ReceiveMax}, {"send", b.FileSendMax, l.SendMax}} { + if f.set > f.allowed { + return connectorBad("the %s file limit can only lower the limit of the kind: at most %s", f.what, formatFileSize(f.allowed)) + } + } + return nil +} + +// why names the layer that sets the limit, in words a person knows. +func (l FileLimit) why(kindTitle string) string { + switch l.Layer { + case LayerOwner: + return "limit set for this bot" + case LayerAddon: + return "limit of " + cmp.Or(kindTitle, "this add-on") + case LayerHost: + return "limit of the platform" + default: + return "limit of this add-on version" + } +} + +// sentence is the limit with the layer that sets it, such as +// "1 MiB (limit of this add-on version)". +func (l FileLimit) sentence(kindTitle string) string { + return formatFileSize(l.Bytes) + " (" + l.why(kindTitle) + ")" +} + +// formatFileSize writes a size in bytes, KiB, MiB, GiB or TiB, with at most +// one decimal. +func formatFileSize(n int64) string { + if n == 1 { + return "1 byte" + } + if n < 1<<10 { + return strconv.FormatInt(n, 10) + " bytes" + } + units := []string{"KiB", "MiB", "GiB", "TiB"} + v := float64(n) / 1024 + i := 0 + for v >= 1024 && i < len(units)-1 { + v /= 1024 + i++ + } + return strings.TrimSuffix(strconv.FormatFloat(v, 'f', 1, 64), ".0") + " " + units[i] +} + +// Sentences to people. Each is built from the limit in effect. + +// sentFileTooBig answers a person whose file the bot cannot read. size is +// zero when the add-on did not say how large the file is. +func sentFileTooBig(l FileLimits, size int64) string { + seen := "" + if size > 0 { + seen = " (" + formatFileSize(size) + ")" + } + return fmt.Sprintf("That file is too large%s: I can read files of up to %s here (%s).", seen, formatFileSize(l.Receive.Bytes), l.Receive.why(l.KindTitle)) +} + +// noteSendTooBig tells a person that a file the agent made stays in the chat. +func noteSendTooBig(name string, l FileLimits) string { + return fmt.Sprintf("%s is too large to send here (the limit is %s). It is in the chat's files.", name, l.Send.sentence(l.KindTitle)) +} + +// refuseAttachTooBig is the text that the attach tool gives the model. +func refuseAttachTooBig(name string, size int64, l FileLimits) error { + return fmt.Errorf("%s is too large to send here (%s; the limit is %s). Tell the person; offer to split it, to make a smaller file, or to paste the content", + name, formatFileSize(size), l.Send.sentence(l.KindTitle)) +} diff --git a/core/services/studio/connectorlimits_guard_test.go b/core/services/studio/connectorlimits_guard_test.go new file mode 100644 index 0000000000..b3ae8a5347 --- /dev/null +++ b/core/services/studio/connectorlimits_guard_test.go @@ -0,0 +1,73 @@ +package studio + +import ( + "go/ast" + "go/parser" + gotoken "go/token" + "regexp" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The file limit is decided in one place (EffectiveFileLimit) and written in +// one place (connectorlimits.go). These files talk about file limits and must +// not carry a number or a sentence of their own: a limit that is typed here +// would be wrong as soon as an add-on or an owner sets another one. +var _ = Describe("the files that use the file limit", func() { + files := []string{"connectors.go", "connectorfiles.go", "attachfile.go", "connectorturn.go"} + // Files with no other use for a large number. + noLargeNumbers := []string{"connectorfiles.go", "attachfile.go", "connectorturn.go"} + unit := regexp.MustCompile(`(?i)\b\d+(\.\d+)?\s*(bytes?|[kmgt]i?b)\b`) + + parse := func(name string) (*gotoken.FileSet, *ast.File) { + fset := gotoken.NewFileSet() + f, err := parser.ParseFile(fset, name, nil, 0) + Expect(err).NotTo(HaveOccurred()) + return fset, f + } + + It("write no size in a sentence", func() { + for _, name := range files { + fset, f := parse(name) + ast.Inspect(f, func(n ast.Node) bool { + if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == gotoken.STRING { + s, _ := strconv.Unquote(lit.Value) + Expect(unit.MatchString(s)).To(BeFalse(), "%s: %q names a size; build it from the limit in effect", fset.Position(lit.Pos()), s) + } + return true + }) + } + }) + + It("read the limit from EffectiveFileLimit, not from the contract constant", func() { + for _, name := range files { + fset, f := parse(name) + ast.Inspect(f, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + Expect(sel.Sel.Name).NotTo(Equal("MaxConnectorFileBytes"), "%s: the inline cap is a layer of EffectiveFileLimit", fset.Position(sel.Pos())) + } + return true + }) + } + }) + + It("hold no byte count", func() { + for _, name := range noLargeNumbers { + fset, f := parse(name) + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.BasicLit: + if x.Kind == gotoken.INT { + v, err := strconv.ParseInt(x.Value, 0, 64) + Expect(err == nil && v < 1024).To(BeTrue(), "%s: %s looks like a byte count", fset.Position(x.Pos()), x.Value) + } + case *ast.BinaryExpr: + Expect(x.Op).NotTo(Equal(gotoken.SHL), "%s: a shift builds a byte count", fset.Position(x.Pos())) + } + return true + }) + } + }) +}) diff --git a/core/services/studio/connectorlimits_internal_test.go b/core/services/studio/connectorlimits_internal_test.go new file mode 100644 index 0000000000..38986247d0 --- /dev/null +++ b/core/services/studio/connectorlimits_internal_test.go @@ -0,0 +1,49 @@ +package studio + +import ( + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("the file limit in effect", func() { + declared := func(n int64) *contract.FileAbility { return &contract.FileAbility{MaxBytes: n} } + + DescribeTable("is the smallest of the host ceiling, the declared ability, the owner setting and the inline cap", + func(ceiling int64, ability *contract.FileAbility, owner int64, want FileLimit) { + Expect(EffectiveFileLimit(ceiling, ability, owner)).To(Equal(want)) + }, + Entry("an add-on that declares nothing gets the inline cap", int64(100<<20), nil, int64(0), FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline}), + Entry("a declared ability above the inline cap leaves the inline cap", int64(100<<20), declared(20<<20), int64(0), FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline}), + Entry("a declared ability below the inline cap wins", int64(100<<20), declared(256<<10), int64(0), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}), + Entry("the host ceiling below the rest wins", int64(128<<10), declared(256<<10), int64(0), FileLimit{Bytes: 128 << 10, Layer: LayerHost}), + Entry("the owner setting below the rest wins", int64(100<<20), declared(512<<10), int64(64<<10), FileLimit{Bytes: 64 << 10, Layer: LayerOwner}), + Entry("the owner setting cannot raise the limit", int64(100<<20), declared(256<<10), int64(50<<20), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}), + Entry("a tie names the layer the owner can change first", int64(100<<20), declared(256<<10), int64(256<<10), FileLimit{Bytes: 256 << 10, Layer: LayerOwner}), + Entry("a ceiling of zero means the default ceiling", int64(0), declared(256<<10), int64(0), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}), + ) + + It("lets the host ceiling bind an add-on that declares nothing", func() { + Expect(EffectiveFileLimit(10<<10, nil, 0)).To(Equal(FileLimit{Bytes: 10 << 10, Layer: LayerHost})) + }) + + DescribeTable("writes a size as a person reads it", + func(n int64, want string) { Expect(formatFileSize(n)).To(Equal(want)) }, + Entry("bytes", int64(900), "900 bytes"), + Entry("one byte", int64(1), "1 byte"), + Entry("KiB", int64(512<<10), "512 KiB"), + Entry("a fraction of KiB", int64(1536), "1.5 KiB"), + Entry("MiB", int64(1<<20), "1 MiB"), + Entry("a fraction of MiB", int64(5<<20+512<<10), "5.5 MiB"), + Entry("GiB", int64(3<<30), "3 GiB"), + Entry("TiB", int64(1<<40), "1 TiB"), + ) + + It("names the layer in words a person knows", func() { + Expect(FileLimit{Bytes: 1 << 20, Layer: LayerInline}.sentence("Slack")).To(Equal("1 MiB (limit of this add-on version)")) + Expect(FileLimit{Bytes: 1 << 20, Layer: LayerOwner}.sentence("Slack")).To(Equal("1 MiB (limit set for this bot)")) + Expect(FileLimit{Bytes: 1 << 20, Layer: LayerHost}.sentence("Slack")).To(Equal("1 MiB (limit of the platform)")) + Expect(FileLimit{Bytes: 1 << 20, Layer: LayerAddon}.sentence("Slack")).To(Equal("1 MiB (limit of Slack)")) + Expect(FileLimit{Bytes: 1 << 20, Layer: LayerAddon}.sentence("")).To(Equal("1 MiB (limit of this add-on)")) + }) +}) diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index cb1d9eeeb3..e743435f6b 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -36,7 +36,6 @@ const ( sentNoFolder = "This agent cannot work right now: its folder is missing." sentNoFile = "I could not read the file you sent." sentNoFiles = "I can only read the text of your message here: files are turned off." - sentFileTooBig = "That file is too large: I can read files of up to 1 MB." sentNoListen = "I cannot listen to voice messages here: no speech-to-text model is set up. Please write your message." sentNoTranscript = "I could not understand your voice message. Please send it again, or write it." @@ -100,6 +99,9 @@ type ConnectorOptions struct { // is stored. Nil: tokens are not checked. The application sets // VerifyTokenWithMe; a test that fakes no platform leaves it nil. VerifyToken func(ctx context.Context, b connectors.Binding, token string) error + // FileCeiling reads the host ceiling of a connector file from the owner + // policy, in bytes. Nil or zero: contract.DefaultFileCeilingBytes. + FileCeiling func(ctx context.Context) (int64, error) Logger *slog.Logger } @@ -823,8 +825,9 @@ func (c *Connectors) transcribe(ctx context.Context, b connectors.Binding, f con // sent; the chat saves it in its own folder. func (c *Connectors) media(ctx context.Context, b connectors.Binding, token string, refs []string) ([]contract.ConnectorFile, string) { var ( - out []contract.ConnectorFile - note string + out []contract.ConnectorFile + note string + limits = c.FileLimits(ctx, b) ) for _, ref := range refs { mctx, cancel := context.WithTimeout(ctx, 60*time.Second) @@ -836,8 +839,12 @@ func (c *Connectors) media(ctx context.Context, b connectors.Binding, token stri f := resp.File var ce *contract.ConnectorError switch { - case (errors.As(err, &ce) && ce.Code == contract.ConnectorTooLarge) || (err == nil && f != nil && len(f.Data) > contract.MaxConnectorFileBytes): - note = sentFileTooBig + case (errors.As(err, &ce) && ce.Code == contract.ConnectorTooLarge) || (err == nil && f != nil && int64(len(f.Data)) > limits.Receive.Bytes): + var size int64 + if f != nil { + size = int64(len(f.Data)) + } + note = sentFileTooBig(limits, size) continue case err != nil || f == nil || len(f.Data) == 0: c.log.Warn("studio: could not read the file of a connector message", "binding", b.ID, "kind", b.Kind, "error", err) diff --git a/core/services/studio/connectors_attach_test.go b/core/services/studio/connectors_attach_test.go index 8f66a260aa..da3d579c9e 100644 --- a/core/services/studio/connectors_attach_test.go +++ b/core/services/studio/connectors_attach_test.go @@ -109,7 +109,7 @@ var _ = Describe("attach_file in a connector chat", func() { } Expect(res[0].Err.Error()).To(ContainSubstring("not found")) Expect(res[1].Err.Error()).To(ContainSubstring("too large")) - Expect(res[1].Err.Error()).To(ContainSubstring("1 MB")) + Expect(res[1].Err.Error()).To(ContainSubstring("1 MiB (limit of this add-on version)")) Expect(res[2].Err.Error()).To(ContainSubstring("not sent")) Expect(res[3].Err.Error()).To(ContainSubstring("folder")) Expect(bot.sentFiles()).To(BeEmpty()) diff --git a/core/services/studio/connectors_filelimits_test.go b/core/services/studio/connectors_filelimits_test.go new file mode 100644 index 0000000000..681c86685d --- /dev/null +++ b/core/services/studio/connectors_filelimits_test.go @@ -0,0 +1,251 @@ +package studio_test + +import ( + "context" + "strings" + "sync/atomic" + + "github.com/mudler/LocalAI/addons/slack/slacktest" + "github.com/mudler/LocalAI/addons/telegram/transport" + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/studiotest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/tests/studiobot" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The file limit of a binding is the smallest of the host ceiling, the ability +// that the kind declares, the owner's setting and the cap of the inline path. +// The specs run the same checks over two real add-ons (Telegram and Slack, on +// their fakes) and over a kind that only a fake declares, so nothing in the +// host knows a platform. + +// limitRig is a connector chat on one platform, as the specs of the limits +// see it. +type limitRig struct { + r *connRig + title string + // ask sends the message from the person. + ask func(text string) + // files is how many files reached the person. + files func() int + // said are the messages the bot sent. + said func() []string +} + +type limitSetup struct { + declared *contract.ConnectorFiles + input studio.ConnectorInput + ceiling int64 + script func(workspace.Actor, string) []studiotest.ToolStep +} + +func (s limitSetup) options(o *studio.ConnectorOptions) { + if s.ceiling > 0 { + o.FileCeiling = func(context.Context) (int64, error) { return s.ceiling, nil } + } +} + +var platforms = map[string]func(limitSetup) limitRig{ + "Telegram": func(s limitSetup) limitRig { + bot := newScriptedBot() + addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()}) + addon.Kind.Files = s.declared + r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(addon), s.script, nil, s.options) + r.bot = bot + s.input.Allow = []string{"11"} + r.bind(s.input) + n := 0 + return limitRig{r: r, title: "Telegram", + ask: func(text string) { n++; bot.say(n, 11, "Ada", "ada", text) }, + files: func() int { return len(bot.sentFiles()) }, + said: bot.answers} + }, + "Slack": func(s limitSetup) limitRig { + r, _ := newSlackRigDeclaring(s.script, studio.ConnectorInput{Allow: []string{"U1"}, FileReceiveMax: s.input.FileReceiveMax, FileSendMax: s.input.FileSendMax}, s.declared, s.options) + return limitRig{r: r.connRig, title: "Slack", + ask: func(text string) { r.slack.Push(slackApp, slacktest.DM("D1", "U1", text)) }, + files: func() int { return len(r.slack.Uploads(slackBot)) }, + said: func() []string { + var out []string + for _, p := range r.answers() { + out = append(out, p.Text) + } + return out + }} + }, +} + +// fakeRuntime is a connector add-on that a spec scripts. +type fakeRuntime func(contract.ConnectorRequest) contract.ConnectorResponse + +func (f fakeRuntime) Connector(_ context.Context, req contract.ConnectorRequest) (contract.ConnectorResponse, error) { + return f(req), nil +} + +func ptr(n int64) *int64 { return &n } + +var _ = Describe("file limits in a connector chat", func() { + body := func(n int) string { return strings.Repeat("x", n) } + // makeAndAttach writes a file of n bytes and attaches it by hand too. + makeAndAttach := func(n int) func(workspace.Actor, string) []studiotest.ToolStep { + return attachScript(map[string][]studiotest.ToolStep{ + "go": {write("/out/data.csv", body(n)), attach("/out/data.csv"), {Say: "Done."}}, + }) + } + // refused returns the text that the model got when the file was too large. + refused := func(r limitRig) string { + res := r.r.h.ResultsFor("attach_file") + Expect(res).To(HaveLen(1)) + Expect(res[0].Err).To(HaveOccurred()) + return res[0].Err.Error() + } + + DescribeTable("the layer that is smallest sets the limit and is named in the sentences", + func(platform string, s limitSetup, size int, want string) { + s.script = makeAndAttach(size) + r := platforms[platform](s) + r.ask("go") + waitFor("the note about the file", func() bool { return len(r.said()) >= 2 }) + Expect(refused(r)).To(ContainSubstring(want), "what the model reads") + Expect(strings.Join(r.said(), "\n")).To(ContainSubstring("the limit is "+want), "what the person reads") + Expect(r.files()).To(BeZero(), "a file over the limit is not sent") + }, + Entry("an add-on that declares a lower limit: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}}, 400, "300 bytes (limit of Telegram)"), + Entry("an add-on that declares a lower limit: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}}, 400, "300 bytes (limit of Slack)"), + Entry("the owner lowers the declared limit: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, input: studio.ConnectorInput{FileSendMax: ptr(100)}}, 150, "100 bytes (limit set for this bot)"), + Entry("the owner lowers the declared limit: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, input: studio.ConnectorInput{FileSendMax: ptr(100)}}, 150, "100 bytes (limit set for this bot)"), + Entry("the host ceiling is the smallest: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, ceiling: 50}, 100, "50 bytes (limit of the platform)"), + Entry("the host ceiling is the smallest: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, ceiling: 50}, 100, "50 bytes (limit of the platform)"), + Entry("an add-on that declares nothing: the inline cap, Telegram", "Telegram", limitSetup{}, 1<<20+10, "1 MiB (limit of this add-on version)"), + Entry("an add-on that declares nothing: the inline cap, Slack", "Slack", limitSetup{}, 1<<20+10, "1 MiB (limit of this add-on version)"), + Entry("an add-on that declares more than the inline path carries: the inline cap", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 50 << 20}}}, 1<<20+10, "1 MiB (limit of this add-on version)"), + ) + + DescribeTable("sends a file that is within the limit", + func(platform string) { + r := platforms[platform](limitSetup{ + declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, + input: studio.ConnectorInput{FileSendMax: ptr(200)}, + script: makeAndAttach(150), + }) + r.ask("go") + waitFor("the file", func() bool { return r.files() >= 1 }) + Expect(r.r.h.ResultsFor("attach_file")[0].Err).NotTo(HaveOccurred()) + }, + Entry("Telegram", "Telegram"), + Entry("Slack", "Slack"), + ) + + Describe("the model note", func() { + It("states the limit in effect instead of a fixed number", func() { + r := platforms["Telegram"](limitSetup{ + declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300 << 10}}, + input: studio.ConnectorInput{FileSendMax: ptr(100 << 10)}, + script: connSay("ok"), + }) + r.ask("hello") + waitFor("the turn", func() bool { return len(r.said()) >= 1 }) + chats, err := r.r.svc.List(context.Background(), alice, 10) + Expect(err).NotTo(HaveOccurred()) + Expect(chats).To(HaveLen(1)) + Expect(r.r.h.TurnPrompt(chats[0].ID)).To(ContainSubstring("100 KiB (limit set for this bot) each")) + }) + }) + + Describe("a kind that only a fake declares", func() { + // chatty is a push kind with its own limits; the fake serves the op + // media with a file of the size a spec sets. + var ( + size atomic.Int64 + sent atomic.Value + calls atomic.Int32 + ) + chatty := studiobot.Addon{ID: "chatty-addon", + Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPush, + Files: &contract.ConnectorFiles{Receive: &contract.FileAbility{MaxBytes: 300}, Send: &contract.FileAbility{MaxBytes: 700}}}, + Runtime: fakeRuntime(func(req contract.ConnectorRequest) contract.ConnectorResponse { + switch req.Op { + case contract.ConnectorOpMedia: + return contract.ConnectorResponse{File: &contract.ConnectorFile{Name: "in.txt", MIME: "text/plain", Data: []byte(strings.Repeat("y", int(size.Load())))}} + case contract.ConnectorOpSend: + calls.Add(1) + sent.Store(req.Text) + } + return contract.ConnectorResponse{} + })} + + bindChatty := func(in studio.ConnectorInput) (*connRig, connectors.Binding, error) { + r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(chatty), connSay("ok"), nil, nil) + in.Kind, in.Allow = "chatty", []string{"11"} + if in.Name == "" { + in.Name = "Chatty bot" + } + if in.Token == "" { + in.Token = "tok" + } + in.Enabled = true + if r.agent.ID == "" { + ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + r.agent = ag + } + in.AgentID = r.agent.ID + info, err := r.conns.CreateConnector(context.Background(), alice, in) + return r, info.Binding, err + } + + It("resolves the limits of its own declaration, with the title it declares", func() { + r, b, err := bindChatty(studio.ConnectorInput{}) + Expect(err).NotTo(HaveOccurred()) + l := r.conns.FileLimits(context.Background(), b) + Expect(l.Receive).To(Equal(studio.FileLimit{Bytes: 300, Layer: studio.LayerAddon})) + Expect(l.Send).To(Equal(studio.FileLimit{Bytes: 700, Layer: studio.LayerAddon})) + Expect([]int64{l.ReceiveMax, l.SendMax}).To(Equal([]int64{300, 700})) + Expect(l.KindTitle).To(Equal("Chatty")) + }) + + It("tells a sender whose file is over the limit of the kind which limit it is", func() { + size.Store(400) + r, b, err := bindChatty(studio.ConnectorInput{}) + Expect(err).NotTo(HaveOccurred()) + Expect(r.conns.Ingest(context.Background(), "chatty-addon", contract.ConnectorInbound{BindingID: b.ID, + ConnectorMessage: contract.ConnectorMessage{Chat: "11", SenderID: "11", Direct: true, Media: []string{"m1"}}})).To(Succeed()) + waitFor("the sentence", func() bool { return calls.Load() >= 1 }) + Expect(sent.Load()).To(Equal("That file is too large (400 bytes): I can read files of up to 300 bytes here (limit of Chatty).")) + }) + + It("lets the owner lower the limit for a bot, and the sentence says so", func() { + size.Store(150) + r, b, err := bindChatty(studio.ConnectorInput{FileReceiveMax: ptr(100)}) + Expect(err).NotTo(HaveOccurred()) + Expect(r.conns.FileLimits(context.Background(), b).Receive).To(Equal(studio.FileLimit{Bytes: 100, Layer: studio.LayerOwner})) + Expect(r.conns.Ingest(context.Background(), "chatty-addon", contract.ConnectorInbound{BindingID: b.ID, + ConnectorMessage: contract.ConnectorMessage{Chat: "11", SenderID: "11", Direct: true, Media: []string{"m1"}}})).To(Succeed()) + waitFor("the sentence", func() bool { return calls.Load() >= 1 }) + Expect(sent.Load()).To(Equal("That file is too large (150 bytes): I can read files of up to 100 bytes here (limit set for this bot).")) + }) + + It("refuses a limit above the one of the kind: an owner can only lower it", func() { + _, _, err := bindChatty(studio.ConnectorInput{FileSendMax: ptr(701)}) + Expect(err).To(MatchError(connectors.ErrInvalid)) + Expect(err).To(MatchError(ContainSubstring("at most 700 bytes"))) + _, _, err = bindChatty(studio.ConnectorInput{FileReceiveMax: ptr(300), FileSendMax: ptr(700)}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("bounds what an owner may set by the host ceiling", func() { + r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(chatty), connSay("ok"), nil, func(o *studio.ConnectorOptions) { + o.FileCeiling = func(context.Context) (int64, error) { return 200, nil } + }) + ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + _, err = r.conns.CreateConnector(context.Background(), alice, studio.ConnectorInput{Kind: "chatty", Name: "c", Token: "t", AgentID: ag.ID, FileSendMax: ptr(300)}) + Expect(err).To(MatchError(ContainSubstring("at most 200 bytes"))) + }) + }) +}) diff --git a/core/services/studio/connectors_slack_test.go b/core/services/studio/connectors_slack_test.go index 3fb5b24440..076a61b5cc 100644 --- a/core/services/studio/connectors_slack_test.go +++ b/core/services/studio/connectors_slack_test.go @@ -11,6 +11,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/connectors" "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" "github.com/mudler/LocalAI/tests/studiobot" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,6 +37,12 @@ func newSlackRig(answer string, allow []string) (*slackRig, connectors.Binding) // newSlackRigWith binds the Slack app with in, over a script. func newSlackRigWith(script func(workspace.Actor, string) []studiotest.ToolStep, in studio.ConnectorInput) (*slackRig, connectors.Binding) { + return newSlackRigDeclaring(script, in, nil, nil) +} + +// newSlackRigDeclaring is newSlackRigWith for a Slack add-on whose kind +// declares files, with options of the manager that the spec changes last. +func newSlackRigDeclaring(script func(workspace.Actor, string) []studiotest.ToolStep, in studio.ConnectorInput, files *contract.ConnectorFiles, opt func(*studio.ConnectorOptions)) (*slackRig, connectors.Binding) { f := slacktest.New() DeferCleanup(f.Close) f.AddApp(slacktest.App{Bot: slackBot, AppToken: slackApp, UserID: "UBOT"}) @@ -43,7 +50,9 @@ func newSlackRigWith(script func(workspace.Actor, string) []studiotest.ToolStep, f.AddUser("U2", "bo", "Bo") rt := slack.New(&slack.API{BaseURL: f.URL, FilesHost: f.FilesHost}) DeferCleanup(rt.Close) - r := newConnRigWith(newShared(), connStore(), studiobot.NewTransport(studiobot.Slack(rt)), script, nil) + addon := studiobot.Slack(rt) + addon.Kind.Files = files + r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(addon), script, nil, opt) in.Kind, in.Token = "slack", slackBot+" "+slackApp b := r.bind(in) waitFor("the Socket Mode link", func() bool { return f.Connected(slackApp) }) diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 5e6e851103..7a3ac9ffd9 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -45,6 +45,10 @@ type ConnectorInput struct { // Options are the choices for the options of the kind. Nil means the // defaults for a new binding and unchanged for an update. Options map[string]string + // FileReceiveMax and FileSendMax are the owner's limits, in bytes, for the + // files the bot reads and sends. Nil: new binding none, update unchanged. + // Zero clears the limit. A limit can only lower what the kind allows. + FileReceiveMax, FileSendMax *int64 } // apply sets the fields of the input that an owner writes on a binding. @@ -63,6 +67,12 @@ func (in ConnectorInput) apply(b *connectors.Binding) { if in.ThreadContext != nil { b.ThreadContextOff = !*in.ThreadContext } + if in.FileReceiveMax != nil { + b.FileReceiveMax = *in.FileReceiveMax + } + if in.FileSendMax != nil { + b.FileSendMax = *in.FileSendMax + } } // ErrTokenRefused means the platform refused the bot token of a binding. @@ -282,6 +292,9 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec if err := c.checkOptions(ctx, b); err != nil { return ConnectorInfo{}, err } + if err := c.checkOwnerLimits(ctx, b, in); err != nil { + return ConnectorInfo{}, err + } if strings.TrimSpace(in.Token) == "" { return ConnectorInfo{}, connectorBad("the bot token is required") } @@ -344,6 +357,9 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co if err := c.checkOptions(ctx, b); err != nil { return ConnectorInfo{}, err } + if err := c.checkOwnerLimits(ctx, b, in); err != nil { + return ConnectorInfo{}, err + } if err := c.checkAgent(ctx, old.Owner, in.AgentID); err != nil { return ConnectorInfo{}, err } diff --git a/core/services/studio/connectorturn.go b/core/services/studio/connectorturn.go index 9101f34000..ede93509ee 100644 --- a/core/services/studio/connectorturn.go +++ b/core/services/studio/connectorturn.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/mudler/LocalAI/core/services/studio/session" - "github.com/mudler/LocalAI/pkg/addons/contract" ) // connectorBlock tells the model of a chat that a messaging app started where @@ -24,9 +23,11 @@ func (s *Service) connectorBlock(ctx context.Context, rec session.ChatRecord, ca } platform := "a messaging app" sends, known := false, false + var limits FileLimits if c := s.connectors; c != nil { if b, err := c.o.Store.GetBinding(ctx, rec.OriginID); err == nil { sends, known = b.SendsFiles(), true + limits = c.FileLimits(ctx, b) platform = b.Kind if kinds, err := c.o.Transport.Kinds(ctx); err == nil { if k, ok := kindFor(kinds, b); ok && k.Title != "" { @@ -46,8 +47,8 @@ func (s *Service) connectorBlock(ctx context.Context, rec session.ChatRecord, ca default: fmt.Fprintf(&sb, " Files you make or change in a turn are attached to your answer automatically. To give them a file that already exists, call %s with its path."+ " Say the file is attached; never say you cannot send files, and never tell them a path."+ - " Up to %d files per turn, %d MB each; documents, data, images, audio, video and zip only, no scripts or HTML. If one cannot be attached, say so.", - attachFileName, maxFilesPerTurn, contract.MaxConnectorFileBytes>>20) + " Up to %d files per turn, %s each; documents, data, images, audio, video and zip only, no scripts or HTML. If one cannot be attached, say so.", + attachFileName, maxFilesPerTurn, limits.Send.sentence(limits.KindTitle)) } return sb.String() } diff --git a/core/services/studio/connectorturn_internal_test.go b/core/services/studio/connectorturn_internal_test.go index c5e1e59e07..57664b1861 100644 --- a/core/services/studio/connectorturn_internal_test.go +++ b/core/services/studio/connectorturn_internal_test.go @@ -62,7 +62,7 @@ var _ = Describe("the turn block of a connector chat", func() { Expect(p1).To(ContainSubstring("attached to your answer automatically")) Expect(p1).To(ContainSubstring("call attach_file with its path"), "the way to give a file that exists") Expect(p1).To(ContainSubstring("never tell them a path")) - Expect(p1).To(ContainSubstring("Up to 5 files per turn, 1 MB each")) + Expect(p1).To(ContainSubstring("Up to 5 files per turn, 1 MiB (limit of this add-on version) each")) Expect(p1).NotTo(ContainSubstring("NOT sent")) Expect(strings.Count(p2, "This chat is on")).To(Equal(1), "a second turn does not repeat the block") Expect(p2).To(Equal(p1)) diff --git a/pkg/addons/contract/connectorfiles.go b/pkg/addons/contract/connectorfiles.go index 9bbe185bfb..bcdc7859f8 100644 --- a/pkg/addons/contract/connectorfiles.go +++ b/pkg/addons/contract/connectorfiles.go @@ -17,6 +17,9 @@ const ( MaxDeclaredFileBytes int64 = 1 << 40 // MaxFileTypes bounds the media types of one direction. MaxFileTypes = 32 + // DefaultFileCeilingBytes is the host ceiling of a file, 100 MB, when the + // owner policy sets none. + DefaultFileCeilingBytes int64 = 100 << 20 ) // FileAbility is what a connector kind can move in one direction.