mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
Signed-off-by: Karl-Gustav Kallasmaa <29797627+KGKallasmaa@users.noreply.github.com>
(cherry picked from commit a93be7cc10)
(cherry picked from commit 4c46293b151e03779e8ad580de9303da91afdc27)
460 lines
15 KiB
Go
460 lines
15 KiB
Go
// SPDX-License-Identifier: MIT
|
|
|
|
// Package tools is the runtime of the Web add-on executable. It serves
|
|
// search and fetch, which replace the built-in web_search and web_fetch of
|
|
// the chats (manifest shadows), and the settings tools of its Settings
|
|
// section.
|
|
//
|
|
// The two chat tools run the code of the built-in tools (pkg/web): the same
|
|
// argument checks, the same SSRF guard on every URL and redirect
|
|
// (pkg/web/safehttp, in this process: the add-on is trusted code with its own
|
|
// network access, and the guard is what keeps a model's URL away from
|
|
// internal addresses), the same text extraction and the same untrusted-content
|
|
// framing. The keyless entry of the order is the built-in's keyless chain
|
|
// (DuckDuckGo, then the Brave Search and Bing result pages, with the same
|
|
// cool-down), so installing the add-on without a key does not make search
|
|
// worse. What the add-on adds is the providers that need a key, tried in the
|
|
// administrator's order, and the text of PDF files.
|
|
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mudler/LocalAI/addons/internal/protocol"
|
|
"github.com/mudler/LocalAI/addons/web/internal/providers"
|
|
"github.com/mudler/LocalAI/addons/web/internal/settings"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
"github.com/mudler/LocalAI/pkg/web/safehttp"
|
|
"github.com/mudler/LocalAI/pkg/web/search"
|
|
"github.com/mudler/LocalAI/pkg/web/webtools"
|
|
)
|
|
|
|
// The tools of the manifest.
|
|
const (
|
|
ToolSearch = "search"
|
|
ToolFetch = "fetch"
|
|
ToolStatus = "status"
|
|
ToolSetSettings = "set-settings"
|
|
ToolTestSearch = "test-search"
|
|
)
|
|
|
|
// Declared is everything the manifest lists under capabilities.tools.
|
|
func Declared() []string {
|
|
return []string{ToolSearch, ToolFetch, ToolStatus, ToolSetSettings, ToolTestSearch}
|
|
}
|
|
|
|
// MaxDownloadBytes is the download cap of fetch. It is larger than the 2 MiB
|
|
// of the built-in web_fetch so that common PDF files fit; the text returned
|
|
// has the same limits as the built-in.
|
|
const MaxDownloadBytes = 10 << 20
|
|
|
|
// settingsHint is where an administrator chooses the providers, for the error
|
|
// a user reads when none works.
|
|
const settingsHint = "an administrator can set the search providers of the Web add-on in Settings"
|
|
|
|
// Admin tells whether the user of a request is an administrator. The host
|
|
// answers; the add-on never decides it.
|
|
type Admin interface {
|
|
IsAdmin(ctx context.Context, operationID string) bool
|
|
}
|
|
|
|
// Endpoints are the addresses of the providers. Tests point them at fakes.
|
|
type Endpoints struct {
|
|
Brave, Tavily, Google, Keenable string
|
|
// Keyless are the result pages of the keyless chain.
|
|
Keyless search.KeylessEndpoints
|
|
}
|
|
|
|
// DefaultEndpoints are the documented addresses.
|
|
func DefaultEndpoints() Endpoints {
|
|
return Endpoints{Brave: providers.BraveEndpoint, Tavily: providers.TavilyEndpoint, Google: providers.GoogleEndpoint, Keenable: providers.KeenableEndpoint, Keyless: search.DefaultKeylessEndpoints()}
|
|
}
|
|
|
|
// Options configure the runtime.
|
|
type Options struct {
|
|
Settings *settings.Store
|
|
// Admin may be nil: the settings tools then refuse every change.
|
|
Admin Admin
|
|
// Fetch reads a URL for fetch and for the keyless pages. Nil: the guarded
|
|
// client of pkg/web/safehttp with the built-in's policy.
|
|
Fetch webtools.Fetcher
|
|
// Endpoints zero: DefaultEndpoints.
|
|
Endpoints Endpoints
|
|
// PDF reads the text of a PDF. Nil: PDFText.
|
|
PDF func([]byte) (string, error)
|
|
// Limits of the searches. Zero: the built-in's.
|
|
Limits search.Limits
|
|
}
|
|
|
|
// NewFetcher returns the guarded client of fetch: public addresses only,
|
|
// ports 80 and 443, at most 5 redirects, each checked, 15 seconds.
|
|
func NewFetcher() *safehttp.Client {
|
|
return safehttp.New(safehttp.Policy{
|
|
MaxBodyBytes: MaxDownloadBytes,
|
|
UserAgent: "LocalAI (web add-on; +https://localai.io)",
|
|
}, nil)
|
|
}
|
|
|
|
type runtime struct {
|
|
o Options
|
|
|
|
mu sync.Mutex
|
|
rev int
|
|
built bool
|
|
tools webtools.Tools
|
|
chain []search.Provider
|
|
fallback *search.Fallback
|
|
}
|
|
|
|
// New returns the runtime of the executable.
|
|
func New(o Options) contract.Runtime {
|
|
if o.Fetch == nil {
|
|
o.Fetch = NewFetcher()
|
|
}
|
|
if o.Endpoints == (Endpoints{}) {
|
|
o.Endpoints = DefaultEndpoints()
|
|
}
|
|
if o.PDF == nil {
|
|
o.PDF = PDFText
|
|
}
|
|
return &runtime{o: o}
|
|
}
|
|
|
|
// build returns the providers of the saved order that are ready, in order,
|
|
// and the reasons of those that are in the order but cannot be built.
|
|
func (r *runtime) build(s settings.Settings) []search.Provider {
|
|
var out []search.Provider
|
|
for _, n := range s.Order {
|
|
if ok, _ := s.Ready(n); !ok {
|
|
continue
|
|
}
|
|
var p search.Provider
|
|
var err error
|
|
switch n {
|
|
case providers.Brave:
|
|
p, err = providers.NewBrave(r.o.Endpoints.Brave, s.Keys[n])
|
|
case providers.Tavily:
|
|
p, err = providers.NewTavily(r.o.Endpoints.Tavily, s.Keys[n])
|
|
case providers.Google:
|
|
p, err = providers.NewGoogle(r.o.Endpoints.Google, s.Keys[n], s.GoogleEngine)
|
|
case providers.Keenable:
|
|
p, err = providers.NewKeenable(r.o.Endpoints.Keenable, s.Keys[n])
|
|
case providers.SearXNG:
|
|
p, err = search.NewSearXNG(s.SearXNGURL)
|
|
case providers.DuckDuckGo:
|
|
// The keyless entry is the built-in's keyless chain, each page
|
|
// reached through the guarded client: their addresses are fixed,
|
|
// the redirects they answer with are not.
|
|
out = append(out, r.keyless()...)
|
|
continue
|
|
}
|
|
if err == nil && p != nil {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// keyless returns the providers of the built-in keyless chain, in its order.
|
|
func (r *runtime) keyless() []search.Provider {
|
|
var out []search.Provider
|
|
for _, n := range search.KeylessOrder {
|
|
if p, err := search.NewKeyless(n, r.o.Fetch, r.o.Endpoints.Keyless); err == nil {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// current returns the tools for the saved settings, building them again after
|
|
// a save. The cache and the limits of the searches start again with them.
|
|
func (r *runtime) current() (webtools.Tools, []search.Provider) {
|
|
if r.o.Settings == nil {
|
|
return webtools.Tools{Fetch: pdfGuard{r.o.Fetch}, PDF: r.o.PDF, SettingsHint: settingsHint}, nil
|
|
}
|
|
s, rev := r.o.Settings.Get()
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.built && rev == r.rev {
|
|
return r.tools, r.chain
|
|
}
|
|
chain := r.build(s)
|
|
t := webtools.Tools{Fetch: pdfGuard{r.o.Fetch}, PDF: r.o.PDF, SettingsHint: settingsHint}
|
|
r.fallback = nil
|
|
if len(chain) > 0 {
|
|
// Like the built-in: a provider that answers with a bot check or
|
|
// refuses this server is skipped for a few minutes. The cool-down is
|
|
// in the memory of this process and starts again with a save.
|
|
r.fallback = search.NewFallback(0, chain...).WithCooldown(search.DefaultCooldown)
|
|
t.Search = search.NewSearcher(r.fallback, r.o.Limits)
|
|
}
|
|
r.tools, r.chain, r.rev, r.built = t, chain, rev, true
|
|
return t, chain
|
|
}
|
|
|
|
// cooling returns the providers of the chain in their cool-down, with why.
|
|
func (r *runtime) cooling() map[string]string {
|
|
r.mu.Lock()
|
|
f := r.fallback
|
|
r.mu.Unlock()
|
|
if f == nil {
|
|
return map[string]string{}
|
|
}
|
|
return f.Cooling()
|
|
}
|
|
|
|
// pdfGuard refuses a PDF that the download cap cut: the rest of the file is
|
|
// missing, so its text cannot be read.
|
|
type pdfGuard struct{ f webtools.Fetcher }
|
|
|
|
func (g pdfGuard) Get(ctx context.Context, raw string, accept []string) (*safehttp.Response, error) {
|
|
resp, err := g.f.Get(ctx, raw, accept)
|
|
if err == nil && resp.Truncated && resp.ContentType == "application/pdf" {
|
|
return nil, fmt.Errorf("the PDF is larger than %d MiB, so it was not read", MaxDownloadBytes>>20)
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
var operationShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
|
|
|
func answer(out string) contract.Result {
|
|
return contract.Result{Payload: protocol.Payload(map[string]any{"ok": true, "output": out})}
|
|
}
|
|
|
|
func refuse(msg string) contract.Result {
|
|
return contract.Result{Payload: protocol.Payload(map[string]any{"ok": false, "error": msg})}
|
|
}
|
|
|
|
// Read serves every tool: the two chat tools, which the host runs as reads
|
|
// without a resource, and the settings tools of the page.
|
|
func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Result, error) {
|
|
var head struct {
|
|
Tool string `json:"tool"`
|
|
}
|
|
if json.Unmarshal(env.Request.Arguments, &head) != nil || head.Tool == "" {
|
|
return contract.Result{}, protocol.ErrBadRequest
|
|
}
|
|
op := env.Request.OperationID
|
|
if !operationShape.MatchString(op) {
|
|
return contract.Result{}, protocol.ErrBadRequest
|
|
}
|
|
switch head.Tool {
|
|
case ToolSearch:
|
|
return r.search(ctx, env)
|
|
case ToolFetch:
|
|
return r.fetch(ctx, env)
|
|
case ToolStatus, ToolSetSettings, ToolTestSearch:
|
|
return r.settingsTool(ctx, env, head.Tool, op)
|
|
}
|
|
return contract.Result{}, protocol.ErrBadRequest
|
|
}
|
|
|
|
// chatError is the answer of a chat tool that failed in a way the model can
|
|
// read and tell the user. A stopped turn stays an error for the host.
|
|
func chatError(err error) (contract.Result, error) {
|
|
if errors.Is(err, context.Canceled) {
|
|
return contract.Result{}, err
|
|
}
|
|
return refuse(webtools.Sanitize(err.Error())), nil
|
|
}
|
|
|
|
func (r *runtime) search(ctx context.Context, env contract.Envelope) (contract.Result, error) {
|
|
var a struct {
|
|
Tool string `json:"tool"`
|
|
Query string `json:"query"`
|
|
MaxResults int `json:"maxResults,omitempty"`
|
|
Freshness string `json:"freshness,omitempty"`
|
|
Site string `json:"site,omitempty"`
|
|
}
|
|
if err := strict(env.Request.Arguments, &a); err != nil {
|
|
return refuse(err.Error()), nil
|
|
}
|
|
// The manifest names the arguments in camel case (the contract allows no
|
|
// underscore); the built-in code reads its own names.
|
|
raw := protocol.Payload(map[string]any{"query": a.Query, "max_results": a.MaxResults, "freshness": a.Freshness, "site": a.Site})
|
|
t, _ := r.current()
|
|
out, err := t.WebSearch(ctx, env.Authority.Actor.UserID, omitZero(raw))
|
|
if err != nil {
|
|
return chatError(err)
|
|
}
|
|
return answer(out), nil
|
|
}
|
|
|
|
func (r *runtime) fetch(ctx context.Context, env contract.Envelope) (contract.Result, error) {
|
|
var a struct {
|
|
Tool string `json:"tool"`
|
|
URL string `json:"url"`
|
|
MaxChars int `json:"maxChars,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
}
|
|
if err := strict(env.Request.Arguments, &a); err != nil {
|
|
return refuse(err.Error()), nil
|
|
}
|
|
raw := protocol.Payload(map[string]any{"url": a.URL, "max_chars": a.MaxChars, "format": a.Format})
|
|
t, _ := r.current()
|
|
out, err := t.WebFetch(ctx, omitZero(raw))
|
|
if err != nil {
|
|
return chatError(err)
|
|
}
|
|
return answer(out), nil
|
|
}
|
|
|
|
// omitZero drops the zero values of an argument object, which the built-in
|
|
// code reads as "not given".
|
|
func omitZero(raw json.RawMessage) json.RawMessage {
|
|
var m map[string]any
|
|
if json.Unmarshal(raw, &m) != nil {
|
|
return raw
|
|
}
|
|
for k, v := range m {
|
|
if v == "" || v == float64(0) {
|
|
delete(m, k)
|
|
}
|
|
}
|
|
return protocol.Payload(m)
|
|
}
|
|
|
|
func strict(raw json.RawMessage, v any) error {
|
|
d := json.NewDecoder(strings.NewReader(string(raw)))
|
|
d.DisallowUnknownFields()
|
|
if err := d.Decode(v); err != nil {
|
|
return fmt.Errorf("%w: %v", webtools.ErrArguments, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestQuery is the search of test-search when the page gives none.
|
|
const TestQuery = "LocalAI"
|
|
|
|
// ProviderTest is the result of one provider in test-search.
|
|
type ProviderTest struct {
|
|
Name string `json:"name"`
|
|
Ready bool `json:"ready"`
|
|
OK bool `json:"ok"`
|
|
Results int `json:"results"`
|
|
// Host is the host that answered.
|
|
Host string `json:"host,omitempty"`
|
|
// Error is why it failed or, for a provider that is not ready, what it
|
|
// needs. It never holds a key.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r *runtime) settingsTool(ctx context.Context, env contract.Envelope, tool, op string) (contract.Result, error) {
|
|
admin := r.o.Admin != nil && r.o.Admin.IsAdmin(ctx, op)
|
|
if r.o.Settings == nil {
|
|
return refuse("the Web add-on cannot read its settings"), nil
|
|
}
|
|
switch tool {
|
|
case ToolStatus:
|
|
_, chain := r.current()
|
|
var serving []string
|
|
for _, p := range chain {
|
|
serving = append(serving, p.Name())
|
|
}
|
|
out := map[string]any{"ok": true, "admin": admin, "serving": serving, "cooling": r.cooling()}
|
|
if admin {
|
|
out["settings"] = r.o.Settings.View()
|
|
}
|
|
return contract.Result{Payload: protocol.Payload(out)}, nil
|
|
case ToolSetSettings:
|
|
if !admin {
|
|
return refuse("only an administrator can change this setting"), nil
|
|
}
|
|
var a struct {
|
|
Tool string `json:"tool"`
|
|
settings.Change
|
|
}
|
|
if err := strict(env.Request.Arguments, &a); err != nil {
|
|
return refuse("the settings are not valid"), nil
|
|
}
|
|
if err := r.o.Settings.Apply(a.Change); err != nil {
|
|
return refuse(err.Error()), nil
|
|
}
|
|
return contract.Result{Payload: protocol.Payload(map[string]any{"ok": true, "settings": r.o.Settings.View()})}, nil
|
|
case ToolTestSearch:
|
|
if !admin {
|
|
return refuse("only an administrator can test the providers"), nil
|
|
}
|
|
var a struct {
|
|
Tool string `json:"tool"`
|
|
Query string `json:"query,omitempty"`
|
|
}
|
|
if err := strict(env.Request.Arguments, &a); err != nil {
|
|
return refuse("the test is not valid"), nil
|
|
}
|
|
return contract.Result{Payload: protocol.Payload(map[string]any{"ok": true, "providers": r.test(ctx, a.Query)})}, nil
|
|
}
|
|
return contract.Result{}, protocol.ErrBadRequest
|
|
}
|
|
|
|
// test runs one search with each provider of the order, one after the other,
|
|
// outside the cache and the limits: it is the administrator's check of each
|
|
// key, not a chat's search.
|
|
func (r *runtime) test(ctx context.Context, query string) []ProviderTest {
|
|
if strings.TrimSpace(query) == "" {
|
|
query = TestQuery
|
|
}
|
|
s, _ := r.o.Settings.Get()
|
|
q, err := search.Normalize(search.Query{Text: query, MaxResults: 3})
|
|
var out []ProviderTest
|
|
for _, n := range s.Order {
|
|
pt := ProviderTest{Name: n}
|
|
ready, missing := s.Ready(n)
|
|
pt.Ready = ready
|
|
if !ready {
|
|
pt.Error = missing
|
|
out = append(out, pt)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
pt.Error = webtools.Sanitize(err.Error())
|
|
out = append(out, pt)
|
|
continue
|
|
}
|
|
built := r.build(settings.Settings{Order: []string{n}, Keys: s.Keys, GoogleEngine: s.GoogleEngine, SearXNGURL: s.SearXNGURL})
|
|
if len(built) == 0 {
|
|
pt.Error = "it could not be set up"
|
|
out = append(out, pt)
|
|
continue
|
|
}
|
|
// The keyless entry is several providers: one row each, so the page
|
|
// shows which pages of the chain answer this server.
|
|
for _, p := range built {
|
|
one := ProviderTest{Name: p.Name(), Ready: true}
|
|
res, from, serr := search.NewFallback(0, p).SearchFrom(ctx, q)
|
|
if serr != nil {
|
|
one.Error = webtools.Sanitize(strings.TrimPrefix(serr.Error(), search.ErrProvider.Error()+": "+p.Name()+": "))
|
|
} else {
|
|
one.OK, one.Results, one.Host = true, len(res), from.Host()
|
|
}
|
|
out = append(out, one)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (*runtime) Preview(context.Context, contract.Envelope) (contract.Preview, error) {
|
|
return contract.Preview{}, errNoTool
|
|
}
|
|
|
|
func (*runtime) Apply(context.Context, contract.Envelope, contract.Revision) (contract.Receipt, error) {
|
|
return contract.Receipt{}, errNoTool
|
|
}
|
|
|
|
func (*runtime) Receipt(context.Context, string) (contract.Receipt, error) {
|
|
return contract.Receipt{}, errNoTool
|
|
}
|
|
|
|
func (*runtime) OpenOutput(context.Context, contract.Envelope, string) (io.ReadCloser, error) {
|
|
return nil, errNoTool
|
|
}
|
|
|
|
var errNoTool = errors.New("this operation is not served by the web add-on")
|