577 lines
13 KiB
Go
577 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/netproxy"
|
|
"github.com/yetone/magpie/internal/settings"
|
|
)
|
|
|
|
//go:embed host.js
|
|
var hostJS []byte
|
|
|
|
// message is a line the host writes.
|
|
type message struct {
|
|
ID int64 `json:"id"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Event string `json:"event"`
|
|
Status int `json:"status"`
|
|
Headers map[string]string `json:"headers"`
|
|
Data string `json:"data"`
|
|
Provider string `json:"provider"`
|
|
Account string `json:"account"`
|
|
Said string `json:"said"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
Title string `json:"title"`
|
|
Variant string `json:"variant"`
|
|
}
|
|
|
|
type call struct {
|
|
done chan message // the answer
|
|
events chan message // a fetch's head and chunks, before its answer
|
|
gone chan struct{} // closed when no one reads events any more
|
|
closed atomic.Bool // done has been answered
|
|
}
|
|
|
|
// host is one Bun process running host.js.
|
|
type host struct {
|
|
cmd *exec.Cmd
|
|
in io.WriteCloser
|
|
wmu sync.Mutex
|
|
mu sync.Mutex
|
|
calls map[int64]*call
|
|
next int64
|
|
dead chan struct{}
|
|
err error
|
|
loaded []Loaded
|
|
}
|
|
|
|
// Loaded is how a plugin fared when the host loaded it.
|
|
type Loaded struct {
|
|
Spec string `json:"spec"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var (
|
|
hostMu sync.Mutex
|
|
current *host
|
|
// generation counts the hosts started, so a caller can tell a
|
|
// restart happened (the provider cache is then stale)
|
|
generation atomic.Int64
|
|
// onChange are told a sign-in or the plugins changed.
|
|
onChange []func()
|
|
onChangeMu sync.Mutex
|
|
)
|
|
|
|
// OnChange registers f to be told when a plugin sign-in or the plugins
|
|
// change (a sign-in saved or refreshed, a plugin added).
|
|
func OnChange(f func()) {
|
|
onChangeMu.Lock()
|
|
onChange = append(onChange, f)
|
|
onChangeMu.Unlock()
|
|
}
|
|
|
|
var (
|
|
onSignInMu sync.Mutex
|
|
onSignIn func(provider, account, said string)
|
|
)
|
|
|
|
// OnSignIn registers f to be told what a plugin said, outside an answer,
|
|
// its account's sign-in is: "expired", "kept" or "renewed" (a models
|
|
// hook's error saying it).
|
|
func OnSignIn(f func(provider, account, said string)) {
|
|
onSignInMu.Lock()
|
|
onSignIn = f
|
|
onSignInMu.Unlock()
|
|
}
|
|
|
|
func changed() {
|
|
forgetProviders()
|
|
onChangeMu.Lock()
|
|
fs := append([]func(){}, onChange...)
|
|
onChangeMu.Unlock()
|
|
for _, f := range fs {
|
|
go f()
|
|
}
|
|
}
|
|
|
|
// Restart stops the host, so the next call starts one with the plugins
|
|
// as they are now.
|
|
func Restart() {
|
|
hostMu.Lock()
|
|
h := current
|
|
current = nil
|
|
hostMu.Unlock()
|
|
if h != nil {
|
|
h.stop()
|
|
}
|
|
changed()
|
|
}
|
|
|
|
// Running is whether a host is up.
|
|
func Running() bool {
|
|
hostMu.Lock()
|
|
defer hostMu.Unlock()
|
|
return current != nil && current.alive()
|
|
}
|
|
|
|
func (h *host) alive() bool {
|
|
select {
|
|
case <-h.dead:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (h *host) stop() {
|
|
h.in.Close()
|
|
select {
|
|
case <-h.dead:
|
|
case <-time.After(2 * time.Second):
|
|
if h.cmd.Process != nil {
|
|
h.cmd.Process.Kill()
|
|
}
|
|
}
|
|
}
|
|
|
|
// hostFile is host.js written where Bun can run it.
|
|
func hostFile() (string, error) {
|
|
sum := sha256.Sum256(hostJS)
|
|
dir := filepath.Join(filepath.Dir(catalog.CachePath()), "plugin-host")
|
|
p := filepath.Join(dir, "host-"+hex.EncodeToString(sum[:6])+".js")
|
|
if b, err := os.ReadFile(p); err == nil && string(b) == string(hostJS) {
|
|
return p, nil
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
tmp := p + ".tmp"
|
|
if err := os.WriteFile(tmp, hostJS, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
return p, os.Rename(tmp, p)
|
|
}
|
|
|
|
// get is the running host, started (Bun downloaded, the plugins loaded)
|
|
// when there is none.
|
|
func get(ctx context.Context) (*host, error) {
|
|
checkList()
|
|
hostMu.Lock()
|
|
defer hostMu.Unlock()
|
|
if hostStale.Swap(false) && current != nil {
|
|
go current.stop()
|
|
current = nil
|
|
}
|
|
if current != nil && current.alive() {
|
|
return current, nil
|
|
}
|
|
h, err := start(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
current = h
|
|
return h, nil
|
|
}
|
|
|
|
func start(ctx context.Context) (*host, error) {
|
|
bun, err := Bun(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
js, err := hostFile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(settings.Dir(), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if catalog.Source() == "" {
|
|
// a plugin's provider has the models models.dev lists for it, as in
|
|
// OpenCode; a magpie that has never fetched them does first
|
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
_ = catalog.Sync(cctx)
|
|
cancel()
|
|
}
|
|
// the host outlives the request that started it
|
|
cmd := bunCommand(context.Background(), bun, settings.Dir(), "run", js)
|
|
cmd.Env = hostEnv(cmd.Env)
|
|
in, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
h := &host{cmd: cmd, in: in, calls: map[int64]*call{}, dead: make(chan struct{})}
|
|
go func() {
|
|
sc := bufio.NewScanner(stderr)
|
|
sc.Buffer(make([]byte, 64<<10), 4<<20)
|
|
for sc.Scan() {
|
|
log.Printf("plugin: %s", sc.Text())
|
|
}
|
|
}()
|
|
go h.read(out)
|
|
generation.Add(1)
|
|
|
|
l := Load()
|
|
type item struct {
|
|
Spec string `json:"spec"`
|
|
Target string `json:"target"`
|
|
Options map[string]any `json:"options,omitempty"`
|
|
}
|
|
var items []item
|
|
for _, e := range l.Plugins {
|
|
if !e.Off {
|
|
items = append(items, item{e.Spec, Target(e.Spec), e.Options})
|
|
}
|
|
}
|
|
var res struct {
|
|
Plugins []Loaded `json:"plugins"`
|
|
}
|
|
ictx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
err = h.call(ictx, "init", map[string]any{
|
|
"authPath": AuthPath(),
|
|
"modelsDevPath": catalog.Source(),
|
|
"directory": settings.Dir(),
|
|
"config": l.Config,
|
|
"plugins": items,
|
|
}, &res)
|
|
if err != nil {
|
|
h.stop()
|
|
return nil, fmt.Errorf("starting plugins: %w", err)
|
|
}
|
|
h.loaded = res.Plugins
|
|
for _, p := range res.Plugins {
|
|
if p.Error != "" {
|
|
log.Printf("plugin %s didn't load: %s", p.Spec, p.Error)
|
|
}
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
func (h *host) read(out io.Reader) {
|
|
r := bufio.NewReaderSize(out, 1<<20)
|
|
for {
|
|
line, err := r.ReadBytes('\n')
|
|
if len(line) > 0 {
|
|
var m message
|
|
if json.Unmarshal(line, &m) == nil {
|
|
h.dispatch(m)
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
err := h.cmd.Wait()
|
|
h.mu.Lock()
|
|
if err == nil {
|
|
err = errors.New("the plugin host quit")
|
|
}
|
|
h.err = fmt.Errorf("the plugin host quit: %v", err)
|
|
calls := h.calls
|
|
h.calls = map[int64]*call{}
|
|
h.mu.Unlock()
|
|
close(h.dead)
|
|
for _, c := range calls {
|
|
if c.closed.CompareAndSwap(false, true) {
|
|
c.done <- message{Error: &struct {
|
|
Message string `json:"message"`
|
|
}{h.err.Error()}}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *host) dispatch(m message) {
|
|
if m.ID == 0 {
|
|
switch m.Event {
|
|
case "auth":
|
|
changed()
|
|
case "signIn":
|
|
onSignInMu.Lock()
|
|
f := onSignIn
|
|
onSignInMu.Unlock()
|
|
if f != nil {
|
|
go f(m.Provider, m.Account, m.Said)
|
|
}
|
|
case "toast":
|
|
log.Printf("plugin: %s %s", m.Title, m.Message)
|
|
case "log":
|
|
log.Printf("plugin [%s]: %s", m.Level, m.Message)
|
|
}
|
|
return
|
|
}
|
|
h.mu.Lock()
|
|
c := h.calls[m.ID]
|
|
if m.Event == "" {
|
|
delete(h.calls, m.ID)
|
|
}
|
|
h.mu.Unlock()
|
|
if c == nil {
|
|
return
|
|
}
|
|
if m.Event != "" {
|
|
if c.events != nil {
|
|
select {
|
|
case c.events <- m:
|
|
case <-c.gone:
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if c.closed.CompareAndSwap(false, true) {
|
|
c.done <- m
|
|
}
|
|
}
|
|
|
|
func (h *host) send(v any) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.wmu.Lock()
|
|
defer h.wmu.Unlock()
|
|
_, err = h.in.Write(append(b, '\n'))
|
|
return err
|
|
}
|
|
|
|
func (h *host) begin(events bool) (int64, *call) {
|
|
c := &call{done: make(chan message, 1), gone: make(chan struct{})}
|
|
if events {
|
|
c.events = make(chan message, 64)
|
|
}
|
|
h.mu.Lock()
|
|
h.next++
|
|
id := h.next
|
|
h.calls[id] = c
|
|
h.mu.Unlock()
|
|
return id, c
|
|
}
|
|
|
|
func (h *host) forget(id int64) {
|
|
h.mu.Lock()
|
|
delete(h.calls, id)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// call asks the host method with params and reads its answer into out.
|
|
func (h *host) call(ctx context.Context, method string, params, out any) error {
|
|
id, c := h.begin(false)
|
|
if err := h.send(map[string]any{"id": id, "method": method, "params": params}); err != nil {
|
|
h.forget(id)
|
|
return err
|
|
}
|
|
select {
|
|
case m := <-c.done:
|
|
if m.Error != nil {
|
|
return errors.New(m.Error.Message)
|
|
}
|
|
if out != nil && len(m.Result) > 0 {
|
|
return json.Unmarshal(m.Result, out)
|
|
}
|
|
return nil
|
|
case <-ctx.Done():
|
|
h.forget(id)
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Call starts the host if need be and asks it method.
|
|
func Call(ctx context.Context, method string, params, out any) error {
|
|
h, err := get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h.call(ctx, method, params, out)
|
|
}
|
|
|
|
// Plugins is how each plugin fared when the host loaded it, starting the
|
|
// host if need be.
|
|
func Plugins(ctx context.Context) ([]Loaded, error) {
|
|
h, err := get(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return h.loaded, nil
|
|
}
|
|
|
|
// FetchRequest is a request a provider's plugin makes.
|
|
type FetchRequest struct {
|
|
Provider string `json:"provider"`
|
|
Account string `json:"account,omitempty"` // the provider's first when ""
|
|
Model string `json:"model"`
|
|
NPM string `json:"npm"`
|
|
URL string `json:"url"`
|
|
Method string `json:"method"`
|
|
Headers map[string]string `json:"headers"`
|
|
Body []byte `json:"-"`
|
|
Session string `json:"session,omitempty"`
|
|
// Proxy is the provider's or the account's own proxy (netproxy.With):
|
|
// "direct", or its URL; "" follows magpie's. Fetch takes it from ctx
|
|
// when it isn't set.
|
|
Proxy string `json:"proxy,omitempty"`
|
|
}
|
|
|
|
// proxyOf is the proxy ctx names for the host: "" when it names none,
|
|
// "direct", or the proxy's URL.
|
|
func proxyOf(ctx context.Context) string {
|
|
switch c := strings.TrimSpace(netproxy.Choice(ctx)); c {
|
|
case "", "direct":
|
|
return c
|
|
default:
|
|
if u, err := netproxy.Parse(c); err == nil {
|
|
return u.String()
|
|
}
|
|
return c
|
|
}
|
|
}
|
|
|
|
// hostEnv is env for the host, its *_PROXY named MAGPIE_*_PROXY: Bun
|
|
// reads *_PROXY once and puts every fetch through them, so a provider set
|
|
// to "direct" couldn't go around them. host.js gives each fetch the proxy
|
|
// they name instead.
|
|
func hostEnv(env []string) []string {
|
|
out := make([]string, 0, len(env))
|
|
for _, e := range env {
|
|
k, v, _ := strings.Cut(e, "=")
|
|
switch strings.ToUpper(k) {
|
|
case "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY":
|
|
out = append(out, "MAGPIE_"+k+"="+v)
|
|
default:
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Fetch sends r through the provider's plugin — its loader's fetch, or
|
|
// Bun's own when it gives none — and streams back the reply.
|
|
func Fetch(ctx context.Context, r FetchRequest) (*http.Response, error) {
|
|
h, err := get(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.Proxy == "" {
|
|
r.Proxy = proxyOf(ctx)
|
|
}
|
|
id, c := h.begin(true)
|
|
params := struct {
|
|
FetchRequest
|
|
Body string `json:"body,omitempty"`
|
|
}{r, base64.StdEncoding.EncodeToString(r.Body)}
|
|
if err := h.send(map[string]any{"id": id, "method": "fetch", "params": params}); err != nil {
|
|
h.forget(id)
|
|
return nil, err
|
|
}
|
|
abort := func() { _ = h.send(map[string]any{"method": "abort", "params": map[string]any{"id": id}}) }
|
|
var head message
|
|
select {
|
|
case head = <-c.events:
|
|
case m := <-c.done:
|
|
if m.Error != nil {
|
|
return nil, errors.New(m.Error.Message)
|
|
}
|
|
return nil, errors.New("the plugin answered no response")
|
|
case <-ctx.Done():
|
|
abort()
|
|
h.forget(id)
|
|
return nil, ctx.Err()
|
|
}
|
|
pr, pw := io.Pipe()
|
|
res := &http.Response{
|
|
StatusCode: head.Status,
|
|
Status: fmt.Sprintf("%d %s", head.Status, http.StatusText(head.Status)),
|
|
Header: http.Header{},
|
|
Body: pr,
|
|
Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1,
|
|
}
|
|
for k, v := range head.Headers {
|
|
res.Header.Set(k, v)
|
|
}
|
|
// the body is decoded already: fetch takes the Content-Encoding off
|
|
res.Header.Del("Content-Encoding")
|
|
res.Header.Del("Content-Length")
|
|
go func() {
|
|
defer close(c.gone)
|
|
write := func(data string) error {
|
|
b, err := base64.StdEncoding.DecodeString(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pw.Write(b)
|
|
return err
|
|
}
|
|
for {
|
|
select {
|
|
case m := <-c.events:
|
|
if err := write(m.Data); err != nil {
|
|
// the reader went away, or the chunk is garbled
|
|
abort()
|
|
h.forget(id)
|
|
pw.CloseWithError(err)
|
|
return
|
|
}
|
|
case m := <-c.done:
|
|
// the chunks came before the answer: any still queued
|
|
// are written first
|
|
drain:
|
|
for {
|
|
select {
|
|
case e := <-c.events:
|
|
if write(e.Data) != nil {
|
|
break drain
|
|
}
|
|
default:
|
|
break drain
|
|
}
|
|
}
|
|
if m.Error != nil {
|
|
pw.CloseWithError(errors.New(m.Error.Message))
|
|
} else {
|
|
pw.Close()
|
|
}
|
|
return
|
|
case <-ctx.Done():
|
|
abort()
|
|
h.forget(id)
|
|
pw.CloseWithError(ctx.Err())
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return res, nil
|
|
}
|