mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
A connection now has a write mode, chosen on the add-on page: off (the default), propose (each change waits for the person to confirm) or auto (a chat of the person makes it at once). The host enforces it for every write tool of an add-on that holds a credential, before the add-on is called, and holds the call to the repository list of the connection. Host: - Binding.WriteMode, stored in both stores and in the connector API (write_mode). Absent means off, so existing bindings stay read only. - A credentialed add-on may declare write tools. They name the resource with the credential slot argument, never resourceId. A tool can set approval "always", a ceiling that keeps a proposal even in auto. - The proposal broker fetches the credential for the preview and again for the apply, so it is never stored. A mode lowered between the proposal and the confirmation refuses the apply. A proposal that awaits a person cannot be confirmed by a chat turn. - New capability name tools.write for the roles a connector kind declares. Add-on, tools (kind write): comment-issue, comment-pr, create-issue, edit-issue (title, close or reopen), add-labels, review-comment-pr. They preview plain facts, refuse a stale target, cap and screen the text for secrets, and keep a receipt journal plus a hidden marker in the text, so a change whose answer was lost is not sent twice. Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node
185 lines
6.7 KiB
Go
185 lines
6.7 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package contract
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// MinCredentialsAPI is the first manifest API version that knows the
|
|
// credentials declaration. A manifest that declares one must accept it.
|
|
const MinCredentialsAPI uint64 = 2
|
|
|
|
// Kinds of a credential slot. Only the token exists in this version; a kind
|
|
// the host does not know is refused at install.
|
|
const CredentialKindToken = "token"
|
|
|
|
// Bounds of the credentials of a manifest and of a request.
|
|
const (
|
|
MaxCredentialSlots = 1
|
|
// MaxResourceKeyBytes is the longest external resource name in a request.
|
|
MaxResourceKeyBytes = 800
|
|
// MaxCredentialBytes is the longest credential in a request.
|
|
MaxCredentialBytes = 4096
|
|
)
|
|
|
|
// CredentialSlot declares that a call of the add-on's tools needs a secret
|
|
// that the person gave the host, such as an access token for a service. The
|
|
// host seals it, keeps it with the person's binding of the add-on and puts it
|
|
// in Request.Credential for each call. The model can neither see nor set it.
|
|
//
|
|
// The slot can also name the argument that carries the external resource a
|
|
// call works on, for example a repository. The host then checks the argument
|
|
// against the list the person gave the binding, before the add-on is called,
|
|
// and puts the host ID of the entry in Request.ResourceID and the key in
|
|
// Request.ResourceKey. The check is the host's, so an add-on cannot widen it.
|
|
type CredentialSlot struct {
|
|
// ID names the slot. It is unique in the manifest.
|
|
ID string `json:"id"`
|
|
// Title is the name the person sees, such as "Access token".
|
|
Title string `json:"title"`
|
|
// Kind is CredentialKindToken.
|
|
Kind string `json:"kind"`
|
|
// ResourceArgument is the name of the string argument that holds the
|
|
// external resource key. A tool whose schema declares it must require it,
|
|
// and a call is refused when the key is not on the list. A tool without it
|
|
// runs with the credential and no resource.
|
|
ResourceArgument string `json:"resourceArgument,omitempty"`
|
|
}
|
|
|
|
// validateCredentials checks the credentials of the manifest. The binding that
|
|
// holds a credential belongs to a connector kind, so the add-on declares one.
|
|
// A tool of such an add-on is a read tool or a write tool. A write tool names
|
|
// its resource with the slot argument, which must be required; the write mode
|
|
// of the person's binding decides whether it runs at all, whether it waits for
|
|
// a confirmation, and the host holds it to the list of the binding like a
|
|
// read. Schema, create and action tools cannot carry a credential.
|
|
func validateCredentials(c Capabilities, api VersionRange) error {
|
|
if len(c.Credentials) == 0 {
|
|
return nil
|
|
}
|
|
if api.Max < MinCredentialsAPI {
|
|
return fmt.Errorf("credentials need api %d", MinCredentialsAPI)
|
|
}
|
|
if len(c.Credentials) > MaxCredentialSlots {
|
|
return fmt.Errorf("at most %d credential slots", MaxCredentialSlots)
|
|
}
|
|
if c.Connector == nil || len(c.Connector.Kinds) == 0 {
|
|
return fmt.Errorf("credentials need a connector kind to hold them")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, s := range c.Credentials {
|
|
if !namespace.MatchString(s.ID) || seen[s.ID] || s.Kind != CredentialKindToken || !plainText(s.Title, 80) {
|
|
return fmt.Errorf("invalid credential slot")
|
|
}
|
|
seen[s.ID] = true
|
|
if s.ResourceArgument != "" && !toolPropertyName.MatchString(s.ResourceArgument) {
|
|
return fmt.Errorf("invalid credential resource argument")
|
|
}
|
|
}
|
|
slot := c.Credentials[0]
|
|
for _, d := range c.ToolDefinitions {
|
|
if d.Kind != ToolKindRead && d.Kind != ToolKindWrite {
|
|
return fmt.Errorf("tool %s: an add-on with credentials declares read and write tools only", d.Name)
|
|
}
|
|
if _, has := toolProperties(d)[ResourceArgument]; has {
|
|
return fmt.Errorf("tool %s: an add-on with credentials names its resources with %s, not %s", d.Name, slot.ResourceArgument, ResourceArgument)
|
|
}
|
|
if slot.ResourceArgument == "" {
|
|
if d.Kind == ToolKindWrite {
|
|
return fmt.Errorf("tool %s: a write tool needs the credential slot to name a resource argument", d.Name)
|
|
}
|
|
continue
|
|
}
|
|
prop, has := toolProperties(d)[slot.ResourceArgument]
|
|
if !has {
|
|
if d.Kind == ToolKindWrite {
|
|
return fmt.Errorf("tool %s: a write tool must declare %s", d.Name, slot.ResourceArgument)
|
|
}
|
|
continue
|
|
}
|
|
var p struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if json.Unmarshal(prop, &p) != nil || p.Type != "string" || !requiredProperty(d, slot.ResourceArgument) {
|
|
return fmt.Errorf("tool %s: %s must be a required string", d.Name, slot.ResourceArgument)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toolProperties(d ToolDefinition) map[string]json.RawMessage {
|
|
var root struct {
|
|
Properties map[string]json.RawMessage `json:"properties"`
|
|
}
|
|
_ = json.Unmarshal(d.InputSchema, &root)
|
|
return root.Properties
|
|
}
|
|
|
|
func requiredProperty(d ToolDefinition, name string) bool {
|
|
var root struct {
|
|
Required []string `json:"required"`
|
|
}
|
|
_ = json.Unmarshal(d.InputSchema, &root)
|
|
for _, r := range root.Required {
|
|
if r == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CredentialResource returns the external resource key a call of the tool
|
|
// carries, per the slot: the empty string for a tool that does not declare the
|
|
// slot's resource argument. found tells whether the tool declares it.
|
|
func (s CredentialSlot) CredentialResource(d ToolDefinition, arguments json.RawMessage) (key string, found bool, err error) {
|
|
if s.ResourceArgument == "" {
|
|
return "", false, nil
|
|
}
|
|
if _, has := toolProperties(d)[s.ResourceArgument]; !has {
|
|
return "", false, nil
|
|
}
|
|
var args map[string]json.RawMessage
|
|
if json.Unmarshal(arguments, &args) != nil {
|
|
return "", true, fmt.Errorf("invalid arguments")
|
|
}
|
|
if json.Unmarshal(args[s.ResourceArgument], &key) != nil || key == "" {
|
|
return "", true, fmt.Errorf("the %s argument is required", s.ResourceArgument)
|
|
}
|
|
return key, true, nil
|
|
}
|
|
|
|
// validResourceKey reports an external resource key that fits a request: not
|
|
// empty, bounded, with no control character.
|
|
func validResourceKey(k string) bool {
|
|
if k == "" || len(k) > MaxResourceKeyBytes || !utf8.ValidString(k) {
|
|
return false
|
|
}
|
|
for _, r := range k {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ErrRateLimited is the add-on's answer that the service behind it asks the
|
|
// caller to wait. Nothing was changed. The host turns it into a tool result
|
|
// that tells the model not to repeat the call before RetryAfter seconds.
|
|
type ErrRateLimited struct {
|
|
// RetryAfter is how many seconds to wait, at least 1.
|
|
RetryAfter int
|
|
}
|
|
|
|
// MaxRetryAfter bounds the wait an add-on may ask for, in seconds.
|
|
const MaxRetryAfter = 3600
|
|
|
|
func (e *ErrRateLimited) Error() string {
|
|
return fmt.Sprintf("the service asks to wait %d s", e.RetryAfter)
|
|
}
|
|
|
|
// Seconds is the wait, kept between 1 and MaxRetryAfter.
|
|
func (e *ErrRateLimited) Seconds() int { return min(max(e.RetryAfter, 1), MaxRetryAfter) }
|