Files
magpie/internal/plugin/providers.go
T

699 lines
21 KiB
Go

package plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/steady"
)
// Method is a way a plugin signs in: "oauth" (a browser, then a code
// pasted back or not) or "api" (a key).
type Method struct {
Type string `json:"type"`
Label string `json:"label"`
// Placeholder is the hint an "api" method gives in its key's field
// (magpie's own field: OpenCode's says "API key")
Placeholder string `json:"placeholder,omitempty"`
}
// KeyTitle is what an "api" method's key is asked as: its label, as
// OpenCode's dialog titles it, unless that only says "API key" (or
// nothing), when it is name's API key.
func (m Method) KeyTitle(name string) string {
l := strings.TrimSpace(m.Label)
if l == "" || strings.EqualFold(l, "API key") {
return name + " API key"
}
return l
}
// Model is a model a plugin's provider serves, as OpenCode lists it.
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
NPM string `json:"npm"` // the AI SDK package it is spoken to with
URL string `json:"url"`
APIID string `json:"apiId"`
Context int `json:"context"`
Input int `json:"input"`
Output int `json:"output"`
Reasoning bool `json:"reasoning"`
Image bool `json:"image"`
Released string `json:"released"`
Variants []string `json:"variants"`
// Free is set by the plugin on a model the plan serves at no cost to
// its allowance (WorkBuddy's "credits": "x0.00")
Free bool `json:"free"`
Cost *struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
} `json:"cost"`
// ImageSaid is whether the plugin (or models.dev) said if it takes
// images: Image false without it is not known
ImageSaid bool `json:"imageSaid"`
}
// Provider is a provider a plugin signs in to.
type Provider struct {
ID string `json:"id"` // OpenCode's: google, github-copilot
Spec string `json:"spec"` // the plugin
Name string `json:"name"`
NPM string `json:"npm"`
API string `json:"api"`
Methods []Method `json:"methods"`
// Icon is the picture the plugin gives the provider, as it said it:
// an https URL or a data:image URI (internal/provider keeps it)
Icon string `json:"icon,omitempty"`
// Usage says the plugin tells each account's allowance (auth.usage)
Usage bool `json:"usage"`
SignedIn bool `json:"signedIn"`
AuthType string `json:"authType"`
AccountID string `json:"accountId"`
Models []Model `json:"models"`
// FellBack says the plugin's models hook couldn't fetch its vendor's
// list and gave the default one back.
FellBack bool `json:"fellBack,omitempty"`
// Accounts are the accounts signed in to it, the one kept under its
// own id first; SignedIn, AuthType and AccountID are that one's.
Accounts []Account `json:"accounts"`
}
// Account is one account a provider is signed in to: Key is where
// plugin-auth.json keeps it (the provider's id, else id#slot).
type Account struct {
Key string `json:"key"`
Type string `json:"type"`
AccountID string `json:"accountId"`
// Hint tells an account with no id from another: the end of its key.
Hint string `json:"hint,omitempty"`
// Models are the ids of the provider's models this account has, when
// the provider has more than one account; none, it has them all.
Models []string `json:"models,omitempty"`
FellBack bool `json:"fellBack,omitempty"`
}
var (
provMu sync.Mutex
provCache []Provider
provGood bool
)
func providersPath() string { return filepath.Join(settings.Dir(), "plugin-providers.json") }
// ListedAt is when the plugins last listed their providers and models, as
// a built-in's list is dated by when it was fetched.
func ListedAt() (time.Time, bool) {
st, err := os.Stat(providersPath())
if err != nil {
return time.Time{}, false
}
return st.ModTime(), true
}
func forgetProviders() {
provMu.Lock()
provGood = false
provMu.Unlock()
}
// Providers asks the plugins for their providers, starting the host if
// need be, and keeps the answer for Cached.
func Providers(ctx context.Context) ([]Provider, error) {
var ps []Provider
if err := Call(ctx, "providers", map[string]any{"proxies": listingProxies()}, &ps); err != nil {
return nil, err
}
provMu.Lock()
ps = keepListed(ps, provCache)
ps = keepUnloaded(ps, provCache)
provCache, provGood = ps, true
provMu.Unlock()
if b, err := json.Marshal(ps); err == nil {
_ = writeWhole(providersPath(), b)
}
return ps, nil
}
// ProxyFor is the proxy choice (netproxy.With's) of the provider's
// account at key, or of the provider for key "": set by the providers
// magpie keeps, which know them.
var ProxyFor func(provider, key string) string
// listingProxies are the proxies each provider's model list is asked
// through, by provider and account key ("" the provider's own): a
// built-in's list is fetched through the account's proxy, so a plugin's
// is too.
func listingProxies() map[string]map[string]string {
out := map[string]map[string]string{}
if ProxyFor == nil {
return out
}
var m map[string]json.RawMessage
if b, err := steady.ReadFile(AuthPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
for k := range m {
id := ProviderOf(k)
if out[id] == nil {
out[id] = map[string]string{"": forHost(ProxyFor(id, ""))}
}
out[id][k] = forHost(ProxyFor(id, k))
}
return out
}
// keepListed is ps with a list a plugin fell back to replaced by the one
// it told last: a built-in whose fetch fails keeps the list it fetched
// last too, so a vendor's hiccup never shrinks the models to the plugin's
// short defaults.
func keepListed(ps, last []Provider) []Provider {
if last == nil {
if b, err := steady.ReadFile(providersPath()); err == nil {
_ = json.Unmarshal(b, &last)
}
}
was := map[string]Provider{}
for _, p := range last {
was[p.Spec+"\x00"+p.ID] = p
}
for i, p := range ps {
l, ok := was[p.Spec+"\x00"+p.ID]
if !ok {
continue
}
if p.FellBack && !l.FellBack && len(l.Models) > 0 {
ps[i].Models, ps[i].FellBack = l.Models, false
}
for j, a := range p.Accounts {
if !a.FellBack {
continue
}
for _, b := range l.Accounts {
if b.Key == a.Key && !b.FellBack && len(b.Models) > 0 {
ps[i].Accounts[j].Models, ps[i].Accounts[j].FellBack = b.Models, false
}
}
}
}
return ps
}
// keepUnloaded is ps with the providers last known of an installed
// plugin that told none this time: one that failed to load (a broken
// update, its files gone, Bun refusing it) keeps its providers, their
// accounts and what moved onto them in sight, its requests failing with
// why, rather than going as if it were removed.
func keepUnloaded(ps, last []Provider) []Provider {
if last == nil {
if b, err := steady.ReadFile(providersPath()); err == nil {
_ = json.Unmarshal(b, &last)
}
}
told := map[string]bool{}
for _, p := range ps {
told[p.Spec] = true
}
installed := map[string]bool{}
for _, e := range Load().Plugins {
installed[e.Spec] = true
}
for _, p := range last {
if installed[p.Spec] && !told[p.Spec] {
ps = append(ps, p)
}
}
return ps
}
// UseCached is for tests: Cached answers with ps, as though the plugins
// had just been asked, without Bun; nil forgets them.
func UseCached(ps []Provider) {
provMu.Lock()
provCache, provGood = ps, ps != nil
provMu.Unlock()
// as asked with the plugins as they are now
listSeen.Lock()
listSeen.stamp, listSeen.set = listStamp(), ps != nil
listSeen.Unlock()
}
// refreshing is Cached's refreshes in the background.
var refreshing sync.WaitGroup
// Refreshed waits for Cached's refreshes in the background to end, the
// host left running: for tests that watch the providers' list on disk.
func Refreshed() { refreshing.Wait() }
// Settle waits for Cached's refreshes to end, then stops the host: for
// tests, whose folders the host runs in go when they end.
func Settle() {
refreshing.Wait()
Restart()
// and no host goes on finishing its calls in a folder going away
hostMu.Lock()
hs := make([]*host, 0, len(retiring))
for h := range retiring {
hs = append(hs, h)
}
hostMu.Unlock()
for _, h := range hs {
h.stop()
}
}
// Cached is the plugins' providers as last asked, without starting the
// host: what is known of them when magpie has only just started. A
// provider's sign-in is read afresh from plugin-auth.json.
func Cached() []Provider {
checkList()
provMu.Lock()
ps := provCache
good := provGood
provMu.Unlock()
if ps == nil {
if b, err := steady.ReadFile(providersPath()); err == nil {
_ = json.Unmarshal(b, &ps)
}
}
if len(Load().Plugins) == 0 {
return nil
}
auth := readAuth()
out := make([]Provider, 0, len(ps))
on := map[string]bool{}
for _, e := range Load().Plugins {
if !e.Off {
on[e.Spec] = true
}
}
for _, p := range ps {
if !on[p.Spec] {
continue
}
was := p.Accounts
p.Accounts = accountsOf(auth, p.ID)
for i, a := range p.Accounts {
for _, w := range was {
if w.Key == a.Key {
p.Accounts[i].Models = w.Models
}
}
}
p.SignedIn = len(p.Accounts) > 0
p.AuthType = ""
if p.SignedIn {
p.AuthType = p.Accounts[0].Type
p.AccountID = p.Accounts[0].AccountID
}
out = append(out, p)
}
if !good && len(Load().Plugins) > 0 {
// refreshed in the background: a sign-in or the plugins changed
refreshing.Add(1)
go func() {
defer refreshing.Done()
if Running() || HasBun() {
_, _ = Providers(context.Background())
}
}()
provMu.Lock()
provGood = true // one refresh at a time; a failure is retried on the next change
provMu.Unlock()
}
return out
}
type storedAuth struct {
Type string `json:"type"`
AccountID string `json:"accountId"`
Email string `json:"email"`
Key string `json:"key"`
Metadata struct {
Email string `json:"email"`
} `json:"metadata"`
}
// ProviderOf is the provider an account key is one of.
func ProviderOf(key string) string {
id, _, _ := strings.Cut(key, "#")
return id
}
// accountsOf are provider's accounts in auth, as the host lists them.
func accountsOf(auth map[string]storedAuth, provider string) []Account {
var out []Account
for k, a := range auth {
if ProviderOf(k) != provider {
continue
}
who := firstNonEmpty(a.AccountID, a.Metadata.Email, a.Email)
acct := Account{Key: k, Type: a.Type, AccountID: who}
if a.Type == "api" && len(a.Key) >= 12 {
acct.Hint = a.Key[len(a.Key)-4:]
}
out = append(out, acct)
}
slices.SortFunc(out, func(a, b Account) int {
switch {
case a.Key == provider:
return -1
case b.Key == provider:
return 1
}
return strings.Compare(a.Key, b.Key)
})
return out
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
func readAuth() map[string]storedAuth {
var m map[string]storedAuth
if b, err := steady.ReadFile(AuthPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
// SignedIn is whether a plugin sign-in is kept for provider.
func SignedIn(provider string) bool {
return len(accountsOf(readAuth(), provider)) > 0
}
// Prompt is a question a sign-in method asks before it starts.
type Prompt struct {
Type string `json:"type"` // text, select
Key string `json:"key"`
Message string `json:"message"`
Placeholder string `json:"placeholder,omitempty"`
Options []struct {
Label string `json:"label"`
Value string `json:"value"`
Hint string `json:"hint,omitempty"`
} `json:"options,omitempty"`
}
// NextPrompt is the method's next question given the answers so far; nil
// when it has asked them all.
func NextPrompt(ctx context.Context, provider string, method int, inputs map[string]string) (*Prompt, error) {
var r struct {
Prompt *Prompt `json:"prompt"`
}
err := Call(ctx, "prompt", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &r)
return r.Prompt, err
}
// Validate is what the method says is wrong with value as the answer to
// key, "" when nothing is.
func Validate(ctx context.Context, provider string, method int, key, value string) (string, error) {
var r struct {
Error *string `json:"error"`
}
if err := Call(ctx, "validate", map[string]any{"provider": provider, "method": method, "key": key, "value": value}, &r); err != nil {
return "", err
}
if r.Error == nil {
return "", nil
}
return *r.Error, nil
}
// Authorization is an OAuth sign-in begun: the page to open, and whether
// the plugin waits for it itself ("auto") or needs the code the page
// shows pasted back ("code").
type Authorization struct {
Session string `json:"session"`
URL string `json:"url"`
Instructions string `json:"instructions"`
Method string `json:"method"`
}
// NewAccount is the account a sign-in goes to when it is a new one: the
// provider's own id while nothing is kept there. A sign-in to an account
// already signed in replaces that one's instead.
const NewAccount = "new"
// Authorize begins an OAuth sign-in to account (a key, or NewAccount).
func Authorize(ctx context.Context, provider string, method int, inputs map[string]string, account string) (Authorization, error) {
var a Authorization
err := Call(ctx, "authorize", map[string]any{"provider": provider, "method": method, "inputs": inputs, "account": account}, &a)
return a, err
}
// Saved is where a sign-in was kept: the provider (a plugin may sign in
// to another than asked) and the account's key.
type Saved struct {
Provider string `json:"provider"`
Account string `json:"account"`
}
// ErrFailed is a sign-in the plugin says failed.
var ErrFailed = errors.New("the sign-in failed")
// failure is ErrFailed with why, where the plugin told it.
func failure(why string) error {
if why == "" {
return ErrFailed
}
return fmt.Errorf("%w: %s", ErrFailed, why)
}
// Finish waits for an OAuth sign-in to finish: an "auto" one on its own,
// a "code" one with the code pasted back. It gives where the sign-in was
// saved.
func Finish(ctx context.Context, session, code string) (Saved, error) {
var r struct {
OK bool `json:"ok"`
Error string `json:"error"`
Saved
}
if err := Call(ctx, "callback", map[string]any{"session": session, "code": code}, &r); err != nil {
return Saved{}, err
}
if !r.OK {
return Saved{}, failure(r.Error)
}
return r.Saved, nil
}
// APIKey signs in to account (a key, or NewAccount) with a key, as an
// "api" method does.
func APIKey(ctx context.Context, provider string, method int, inputs map[string]string, key, account string) (Saved, error) {
var r struct {
OK bool `json:"ok"`
Error string `json:"error"`
Saved
}
if err := Call(ctx, "apiKey", map[string]any{"provider": provider, "method": method, "inputs": inputs, "key": key, "account": account}, &r); err != nil {
return Saved{}, err
}
if !r.OK {
return Saved{}, failure(r.Error)
}
return r.Saved, nil
}
// SignOut forgets one account of the provider's, every one when account
// is "".
func SignOut(ctx context.Context, provider, account string) error {
if Running() {
return Call(ctx, "signOut", map[string]any{"provider": provider, "account": account}, nil)
}
var m map[string]json.RawMessage
b, err := steady.ReadFile(AuthPath())
if err != nil {
return nil
}
if err := json.Unmarshal(b, &m); err != nil {
return err
}
for k := range m {
if k == account || account == "" && ProviderOf(k) == provider {
delete(m, k)
}
}
b, _ = json.MarshalIndent(m, "", " ")
if err := writeWhole(AuthPath(), append(b, '\n')); err != nil {
return err
}
changed()
return nil
}
// Options is what the provider's auth loader gave: where its requests
// go, the key the AI SDK would send, and whether it carries them itself.
type Options struct {
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
Headers map[string]string `json:"headers"`
Fetch bool `json:"fetch"`
}
var (
optMu sync.Mutex
optCache = map[string]Options{}
optGen int64
)
// LoaderOptions runs the provider's auth loader for account (once per
// sign-in and host) and gives what it returned; account "" is the
// provider's first.
func LoaderOptions(ctx context.Context, provider, account string) (Options, error) {
ck := provider + "\x00" + account
optMu.Lock()
if optGen != generation.Load() {
optCache, optGen = map[string]Options{}, generation.Load()
}
o, ok := optCache[ck]
optMu.Unlock()
if ok {
return o, nil
}
if err := Call(ctx, "load", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &o); err != nil {
return Options{}, err
}
optMu.Lock()
if optGen == generation.Load() {
optCache[ck] = o
}
optMu.Unlock()
return o, nil
}
func init() {
OnChange(func() {
optMu.Lock()
optCache = map[string]Options{}
optMu.Unlock()
})
}
// Import keeps auth (a plugin-auth.json entry) as one more of provider's
// accounts — or, the same account as one kept already, in its place — and
// gives the account's key. The plugin's host is started for it: it alone
// writes the file while it runs.
func Import(ctx context.Context, provider string, auth map[string]any) (string, error) {
var r struct {
Account string `json:"account"`
}
if err := Call(ctx, "import", map[string]any{"provider": provider, "auth": auth}, &r); err != nil {
return "", err
}
return r.Account, nil
}
// Checked is what trying an account gave: the model ids the plugin lists
// for it, its usage read (nil when the plugin tells none), and why its
// models hook said the vendor refused the sign-in, if it did.
type Checked struct {
Models []string `json:"models"`
Usage *Usage `json:"usage"`
Refused string `json:"refused"`
}
// Check tries one of provider's accounts as a request would — its auth
// loader, then its models as the plugin lists them for it — and reads its
// usage, which asks the vendor of the account itself. It fails when the
// plugin reached none of the places it asked (the vendor offline): a
// models hook falling back to a list it keeps proves nothing.
func Check(ctx context.Context, provider, account string) (Checked, error) {
var r Checked
if err := Call(ctx, "check", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &r); err != nil {
return Checked{}, err
}
return r, nil
}
// Auths are provider's sign-ins as plugin-auth.json keeps them, by key.
func Auths(provider string) map[string]map[string]any {
var m map[string]map[string]any
if b, err := steady.ReadFile(AuthPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
out := map[string]map[string]any{}
for k, v := range m {
if ProviderOf(k) == provider {
out[k] = v
}
}
return out
}
// Take gives provider's accounts named by keys (every one when there are
// none) and signs them out in one step, so the plugin renews none of their
// tokens after it gave them.
func Take(ctx context.Context, provider string, keys []string) (map[string]map[string]any, error) {
var r struct {
Auths map[string]map[string]any `json:"auths"`
}
if err := Call(ctx, "take", map[string]any{"provider": provider, "accounts": keys}, &r); err != nil {
return nil, err
}
return r.Auths, nil
}
// Restore puts auth back as provider's account key.
func Restore(ctx context.Context, provider, key string, auth map[string]any) error {
return Call(ctx, "import", map[string]any{"provider": provider, "key": key, "auth": auth}, nil)
}
// Usage is how much of its allowance one account has used, as the
// plugin's auth.usage tells it (host.js has the shape).
type Usage struct {
Plan string `json:"plan"`
Until string `json:"until"` // RFC 3339, "" when not told
Renew string `json:"renew"`
Balance string `json:"balance"`
Error string `json:"error"`
User string `json:"user"`
SignIn string `json:"signIn"` // "expired", "kept", "renewed" or ""
Windows []UsageWindow `json:"windows"`
Resets *UsageResets `json:"resets"`
}
// UsageResets are the rate-limit resets an account may spend.
type UsageResets struct {
Count int `json:"count"`
Until string `json:"until"`
ByWindow bool `json:"byWindow"`
FiveHour int `json:"fiveHour"`
Weekly int `json:"weekly"`
}
type UsageWindow struct {
Name string `json:"name"`
Used float64 `json:"used"` // percent
ResetsAt string `json:"resetsAt"` // RFC 3339
ResetSecs int64 `json:"resetSecs"`
Display string `json:"display"`
Span float64 `json:"span"` // seconds
Model string `json:"model"`
Models []string `json:"models"`
NotModels []string `json:"notModels"`
Aside bool `json:"aside"`
}
// AccountUsage asks the plugin for account's usage of provider.
func AccountUsage(ctx context.Context, provider, account string) (Usage, error) {
var u Usage
err := Call(ctx, "usage", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &u)
return u, err
}