diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 51bbc738a4..9720ba1143 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -24,7 +24,15 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector { } return schema.ChatsConnector{ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny, Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created, - ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ThreadContext: i.ReadsThread(), Options: i.Options} + ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ThreadContext: i.ReadsThread(), Options: i.Options, + Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax} +} + +func filesOut(l studiosvc.FileLimits) schema.ChatsConnectorFiles { + dir := func(e studiosvc.FileLimit, declared, allowed int64) schema.ChatsConnectorFileLimit { + return schema.ChatsConnectorFileLimit{EffectiveBytes: e.Bytes, Layer: string(e.Layer), DeclaredBytes: declared, AllowedBytes: allowed} + } + return schema.ChatsConnectorFiles{Receive: dir(l.Receive, l.ReceiveDeclared, l.ReceiveMax), Send: dir(l.Send, l.SendDeclared, l.SendMax), InlineBytes: contract.MaxConnectorFileBytes} } // acceptAnyConfirmation is what a request must say to turn on accept_any. @@ -38,7 +46,8 @@ func failAcceptAny(c echo.Context) error { func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput { return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled, AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat, - Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ThreadContext: req.ThreadContext, Options: req.Options} + Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ThreadContext: req.ThreadContext, Options: req.Options, + FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes} } var connectorRules = []errRule{ @@ -99,7 +108,8 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error { } out := schema.ChatsConnectorKindList{Kinds: make([]schema.ChatsConnectorKind, 0, len(kinds))} for _, k := range kinds { - out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options)}) + out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options), + Files: filesOut(host.KindFileLimits(c.Request().Context(), k))}) } return c.JSON(http.StatusOK, out) } diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index 9dbbafb25d..bb092b67a0 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -113,6 +113,38 @@ var _ = Describe("Chats connectors endpoints", func() { Expect(create("alice", map[string]any{"kind": "carrier-pigeon", "name": "x", "token": specToken, "agent_id": ag}).Code).To(Equal(http.StatusBadRequest)) }) + It("shows the file limits of a kind and of a binding, and lets the owner only lower them", func() { + kinds := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/connectors/kinds", nil))["kinds"].([]any) + files := kinds[0].(map[string]any)["files"].(map[string]any) + // The Telegram add-on declares no ability: the inline cap is the limit. + Expect(files).To(HaveKeyWithValue("inline_bytes", BeNumerically("==", 1<<20))) + send := files["send"].(map[string]any) + Expect(send).To(And(HaveKeyWithValue("effective_bytes", BeNumerically("==", 1<<20)), HaveKeyWithValue("layer", "inline"), + HaveKeyWithValue("allowed_bytes", BeNumerically("==", 1<<20)), HaveKeyWithValue("declared_bytes", BeNumerically("==", 0)))) + + ag := agent("alice", "Support", "test-model") + made := create("alice", map[string]any{"name": "B", "token": specToken, "agent_id": ag, "file_send_max_bytes": 2 << 20}) + Expect(made.Code).To(Equal(http.StatusBadRequest), "a limit above the one of the kind is refused: %s", made.Body.String()) + Expect(made.Body.String()).To(ContainSubstring("can only lower")) + + made = create("alice", map[string]any{"name": "B", "token": specToken, "agent_id": ag, "file_send_max_bytes": 100 << 10}) + Expect(made.Code).To(Equal(http.StatusCreated), made.Body.String()) + b := decodeJSON(made) + Expect(b).To(HaveKeyWithValue("file_send_max_bytes", BeNumerically("==", 100<<10))) + Expect(b["files"].(map[string]any)["send"]).To(And(HaveKeyWithValue("effective_bytes", BeNumerically("==", 100<<10)), HaveKeyWithValue("layer", "owner"))) + Expect(b["files"].(map[string]any)["receive"]).To(HaveKeyWithValue("layer", "inline")) + id := b["id"].(string) + + // An update that says nothing about the limits keeps them; zero clears one. + keep := s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "B2", "agent_id": ag, "enabled": true}) + Expect(keep.Code).To(Equal(http.StatusOK), keep.Body.String()) + Expect(decodeJSON(keep)).To(HaveKeyWithValue("file_send_max_bytes", BeNumerically("==", 100<<10))) + clear := s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "B2", "agent_id": ag, "enabled": true, "file_send_max_bytes": 0}) + Expect(decodeJSON(clear)).To(HaveKeyWithValue("file_send_max_bytes", BeNumerically("==", 0))) + Expect(decodeJSON(clear)["files"].(map[string]any)["send"]).To(HaveKeyWithValue("layer", "inline")) + Expect(s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "B2", "agent_id": ag, "enabled": true, "file_receive_max_bytes": -5}).Code).To(Equal(http.StatusBadRequest)) + }) + It("keeps bindings private to their owner", func() { ag := agent("alice", "Support", "test-model") id := decodeJSON(create("alice", map[string]any{"name": "B", "token": specToken, "agent_id": ag}))["id"].(string) diff --git a/core/schema/chats.go b/core/schema/chats.go index 9060f6b212..35b9c55fe7 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1189,6 +1189,40 @@ type ChatsConnector struct { Active bool `json:"active"` // Reason says in plain words why the binding is not active. Reason string `json:"reason,omitempty"` + // Files are the limits of the files the bot reads and sends, with the + // limit that you set for this bot. + Files ChatsConnectorFiles `json:"files"` + // FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot; + // zero means you set none and the limit of the kind applies. + FileReceiveMaxBytes int64 `json:"file_receive_max_bytes"` + FileSendMaxBytes int64 `json:"file_send_max_bytes"` +} + +// ChatsConnectorFiles are the file limits of a binding or of a kind, for both +// directions. +type ChatsConnectorFiles struct { + // Receive is the limit of the files that people send to the bot. + Receive ChatsConnectorFileLimit `json:"receive"` + // Send is the limit of the files that the bot sends. + Send ChatsConnectorFileLimit `json:"send"` + // InlineBytes is the cap of the inline path. Until the add-on can stream + // a file, nothing larger moves, whatever the other limits are. + InlineBytes int64 `json:"inline_bytes"` +} + +// ChatsConnectorFileLimit is the limit of one direction. +type ChatsConnectorFileLimit struct { + // EffectiveBytes is the largest file that moves now: the smallest of the + // host ceiling, the declared ability, your limit and the inline cap. + EffectiveBytes int64 `json:"effective_bytes"` + // Layer names the one that sets EffectiveBytes: owner (your limit for + // this bot), addon (the ability the kind declares), host (the ceiling of + // the server) or inline (the cap of the inline path). + Layer string `json:"layer"` + // DeclaredBytes is the ability the kind declares, or zero. + DeclaredBytes int64 `json:"declared_bytes"` + // AllowedBytes is the most you may set for a bot. + AllowedBytes int64 `json:"allowed_bytes"` } // ChatsConnectorKind is a kind of binding that an installed connector add-on serves. @@ -1205,6 +1239,9 @@ type ChatsConnectorKind struct { ThreadHistory bool `json:"thread_history,omitempty"` // Options are the choices the add-on lets each binding of this kind make. Options []ChatsConnectorOption `json:"options,omitempty"` + // Files are the limits of a binding of this kind that has no limit of + // your own. + Files ChatsConnectorFiles `json:"files"` } // ChatsConnectorOption is one choice of a connector kind. @@ -1260,6 +1297,12 @@ type ChatsConnectorWrite struct { // Options are the choices for the options of the kind, by option id. Absent // means the defaults for a new binding and unchanged for an update. Options map[string]string `json:"options,omitempty"` + // FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads + // and sends, in bytes. A limit can only lower the limit of the kind, which + // the kind list shows as allowed_bytes. Zero removes your limit. Absent + // means none for a new binding and unchanged for an update. + FileReceiveMaxBytes *int64 `json:"file_receive_max_bytes,omitempty"` + FileSendMaxBytes *int64 `json:"file_send_max_bytes,omitempty"` } // ChatsConnectorList is the response of the connector list endpoint. diff --git a/core/services/studio/connectorlimits.go b/core/services/studio/connectorlimits.go index 593cbef622..a78f0fa2dd 100644 --- a/core/services/studio/connectorlimits.go +++ b/core/services/studio/connectorlimits.go @@ -66,6 +66,9 @@ type FileLimits struct { // 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 + // ReceiveDeclared and SendDeclared are the abilities the kind declares; + // zero when it declares none. + ReceiveDeclared, SendDeclared int64 // KindTitle is the name of the platform, for the sentences. KindTitle string } @@ -99,6 +102,12 @@ func (c *Connectors) FileLimits(ctx context.Context, b connectors.Binding) FileL return resolveFileLimits(c.fileCeiling(ctx), files, title, b) } +// KindFileLimits resolves the limits of a kind for a binding that has no +// limit of its own, such as one that is not made yet. +func (c *Connectors) KindFileLimits(ctx context.Context, k ConnectorKind) FileLimits { + return resolveFileLimits(c.fileCeiling(ctx), k.Files, k.Title, connectors.Binding{}) +} + func resolveFileLimits(ceiling int64, files *contract.ConnectorFiles, title string, b connectors.Binding) FileLimits { if files == nil { files = &contract.ConnectorFiles{} @@ -108,6 +117,12 @@ func resolveFileLimits(ceiling int64, files *contract.ConnectorFiles, title stri l.Send = EffectiveFileLimit(ceiling, files.Send, b.FileSendMax) l.ReceiveMax = ownerMax(ceiling, files.Receive) l.SendMax = ownerMax(ceiling, files.Send) + if files.Receive != nil { + l.ReceiveDeclared = files.Receive.MaxBytes + } + if files.Send != nil { + l.SendDeclared = files.Send.MaxBytes + } return l } diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 7a3ac9ffd9..a9680607ce 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -127,6 +127,8 @@ type ConnectorInfo struct { // installed; Reason then says so in plain words. Active bool Reason string + // Files are the file limits in effect for the binding. + Files FileLimits } func info(b connectors.Binding) ConnectorInfo { @@ -137,6 +139,7 @@ func info(b connectors.Binding) ConnectorInfo { // withState marks a binding whose add-on does not run. func (c *Connectors) withState(ctx context.Context, i ConnectorInfo) ConnectorInfo { + i.Files = c.FileLimits(ctx, i.Binding) kinds, err := c.o.Transport.Kinds(ctx) if err != nil { return i