Files
yetone 7d0e415e62 plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)
neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
  plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.

What changed:
- host.js:
  - init gives each loaded plugin the ids it signs in to (provides) and,
    for each id another plugin serves, that plugin's spec (servedBy).
  - auths() honours plugins.json's new prefer map (id -> spec).
  - Only the serving plugin's provider.models hook runs for the id.
- plugin:
  - Clashes() turns provides and servedBy into {id, by, with} per spec.
  - Prefer() records a pick and restarts the host.
  - Remove drops the removed plugin's picks.
  - Re-adding a plugin under a new spec carries its picks over.
  - remove, off, options and prefer find a plugin by its exact spec
    before its package name (indexOf), so acting on one plugin never
    reaches another of the same package.
  - keepUnloaded no longer re-adds a provider from before when another
    plugin serves its id now.
- GUI:
  - /api/plugins rows carry clashes, with the provider's listed name.
  - POST /api/plugins/prefer {spec, provider}.
  - The serving row says "{other} signs in to {name} too; this one
    serves it".
  - The other row says, in amber, "{name} is served by {other}, which
    signs in to it too", with a "Use for {name}" button.
  - New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
  fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
  site/public/docs/{,zh/,ja/}plugins.html.

What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
  plugin and a folder plugin, both on id "acme".
  - Old binary: the local row had providers [] and /api/providers
    listed acme twice.
  - New binary: both rows carry the clash; prefer moves acme to the
    folder plugin; remove clears the pick and leaves the other plugin
    serving acme.
- New Go tests:
  - TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
    off/on and Remove.
  - TestClashes, TestPluginNamedExactlyFirst and
    TestKeepUnloadedLeavesAServedID.
  - TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
  - Each fails when its piece is broken by hand: prefer ignored in
    host.js, the keepUnloaded id check removed, name before exact spec,
    Remove keeping picks, no provides, no clashes in the rows, no
    prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
  and de, at 1100 and 560px: 22/22 pass. It times out on the old
  plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
  248 tests.
  - 245 passed in the 4-wide run.
  - plugin-moved-signin (webkit) failed once in that run, then passed
    3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
  nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
  pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
  chip rules, both kept): plugin, gui and the root plugin tests, vet on
  darwin, linux and windows, and the 248 GUI tests above, all green
  (248/248 this time).

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
2026-10-10 00:55:38 +08:00

877 lines
28 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"`
// Rate is set by the plugin on a model whose price its vendor lists:
// the credits a request costs, as a multiple (Qoder's price_factor
// 0.5, WorkBuddy's "credits": "x0.03"), and RateWas the price before a
// discount running now. The plugin gives either as a number or as the
// vendor writes it ("x0.03"), on the model it lists (m.rate,
// m.rateWas); 0 is none.
Rate float64 `json:"rate"`
RateWas float64 `json:"rateWas"`
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"`
// Fast is set by the plugin on a model it runs in its vendor's fast
// mode when the request says service_tier "priority" (m.fast: Cursor's
// plugin from 0.2.2, for a model Cursor has a fast variant of)
Fast bool `json:"fast"`
}
// 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"`
// Checkin says the plugin presses its vendor's daily check-in for each
// account (auth.checkin)
Checkin bool `json:"checkin,omitempty"`
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"`
// ListError is why it fell back, as the models hook threw it or its
// last fetch failed: the editor says so over the short list.
ListError string `json:"listError,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"`
// MaxConcurrency is how many requests the plugin says each of its
// accounts takes at once (magpie's own field, which OpenCode ignores:
// the auth hook's maxConcurrency, else package.json's
// magpie.maxConcurrency); 0 for none said. The user's setting on the
// provider goes over it.
MaxConcurrency int `json:"maxConcurrency,omitempty"`
}
// 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"`
ListError string `json:"listError,omitempty"`
}
var (
provMu sync.Mutex
provWriteMu sync.Mutex
provCache []Provider
// Failed reads keep the old list. Cached tries once per invalidation,
// with bounded retries, then waits for a sign-in or plugin change.
provGood bool
provBusy bool
provTried bool
provCancel context.CancelFunc
// provEpoch counts the invalidations (forgetProviders), so a refresh
// that began before one doesn't put what it read over the newer state.
provEpoch uint64
)
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
provTried = false
provEpoch++
if provCancel != nil {
provCancel()
}
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
}
ps = commitProviders(ps, nil)
return ps, nil
}
// commitProviders keeps ps for Cached and on disk, with a list a plugin
// fell back to replaced by the one it told last, and gives what was kept.
// epoch, when given, is the invalidation the read began under: a newer
// one that went by meanwhile stands, and ps is then only given back.
func commitProviders(ps []Provider, epoch *uint64) []Provider {
// Serialize publishers without blocking Cached on Windows rename retries.
provWriteMu.Lock()
defer provWriteMu.Unlock()
provMu.Lock()
if epoch != nil && provEpoch != *epoch {
provMu.Unlock()
return ps
}
ps = keepListed(ps, provCache)
ps = keepUnloaded(ps, provCache)
provCache, provGood, provTried = ps, true, true
provMu.Unlock()
if b, err := json.Marshal(ps); err == nil {
_ = writeWhole(providersPath(), b)
}
return ps
}
// refreshDeadline bounds one providers RPC after host initialization: a plugin host
// that stays alive but never answers the providers call must not leave
// the refresh (and Refreshed, and Settle) waiting for ever.
var refreshDeadline = 30 * time.Second
// refreshTries is how many times one background refresh asks before it
// gives up until the next invalidation; refreshBackoff is how long it waits
// before the second ask, doubled for each one after.
var (
refreshTries = 3
refreshBackoff = 250 * time.Millisecond
)
// refreshProviders is one bounded ask of the plugins for their providers,
// kept for Cached when it was answered while the invalidation it began
// under still stood. Tests replace it to stall or fail an ask without Bun.
var refreshProviders = func(ctx context.Context, epoch uint64) error {
var ps []Provider
if err := callWithTimeout(ctx, "providers", map[string]any{"proxies": listingProxies()}, &ps, refreshDeadline); err != nil {
return err
}
commitProviders(ps, &epoch)
return nil
}
// refreshProvidersWithRetry asks the plugins for their providers with
// a deadline, retrying a failure with backoff a bounded number of times.
// A failure keeps the providers already kept, as it keeps the ones on
// disk; a newer invalidation during the refresh is not overwritten by it.
func refreshProvidersWithRetry(ctx context.Context, epoch uint64) {
wait := refreshBackoff
for try := 0; try < refreshTries; try++ {
if try > 0 {
select {
case <-time.After(wait):
case <-ctx.Done():
return
}
wait *= 2
}
err := refreshProviders(ctx, epoch)
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
// a failure keeps what is kept already, and is retried; one that
// began before a newer invalidation stops here rather than going
// on with answers for a state that is gone
provMu.Lock()
stale := provEpoch != epoch
provMu.Unlock()
if stale {
return
}
}
}
// Keep the same admission across intervening changes, so Refreshed waits for
// the latest state too. Invalidation cancels only the old listing, not startup.
func refreshProvidersInBackground(ctx context.Context, epoch uint64) {
defer refreshing.Done()
for {
if Running() || HasBun() {
refreshProvidersWithRetry(ctx, epoch)
}
provMu.Lock()
provCancel()
if provEpoch != epoch && !provGood && !provTried {
epoch = provEpoch
ctx, provCancel = context.WithCancel(context.Background())
provTried = true
provMu.Unlock()
continue
}
provBusy, provCancel = false, nil
provMu.Unlock()
return
}
}
// 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, ps[i].ListError = l.Models, false, ""
// the first account's list is the provider's: it is kept with it
if len(ps[i].Accounts) > 0 && ps[i].Accounts[0].Models == nil {
ps[i].Accounts[0].FellBack, ps[i].Accounts[0].ListError = 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, ps[i].Accounts[j].ListError = 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, ids := map[string]bool{}, map[string]bool{}
for _, p := range ps {
told[p.Spec], ids[p.ID] = true, true
}
installed := map[string]bool{}
for _, e := range list().Plugins {
installed[e.Spec] = true
}
for _, p := range last {
// a provider another plugin serves now (two plugins sign in to
// it, and the other was installed or picked for it) isn't kept as
// this one's too: it would be listed twice under one id
if installed[p.Spec] && !told[p.Spec] && !ids[p.ID] {
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, provTried = ps, ps != nil, ps != nil
// a refresh in flight began under what this replaces: what it read
// must not go over it
provEpoch++
if provCancel != nil {
provCancel()
}
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. Each ask is
// bounded and the retries are few, so this waits a bounded time even for
// a host that never answers.
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()
l := list() // one read of plugins.json for the whole call
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(l.Plugins) == 0 {
return nil
}
auth := readAuth()
out := make([]Provider, 0, len(ps))
on := map[string]bool{}
for _, e := range l.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, p.Accounts[i].FellBack, p.Accounts[i].ListError = w.Models, w.FellBack, w.ListError
}
}
}
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(l.Plugins) > 0 {
// refreshed in the background: a sign-in or the plugins changed.
// Whether one is already in flight is read and set under one lock,
// so two callers asking at once start one refresh, not two.
provMu.Lock()
start := !provBusy && !provGood && !provTried
var ctx context.Context
if start {
ctx, provCancel = context.WithCancel(context.Background())
provBusy, provTried = true, true
refreshing.Add(1)
}
epoch := provEpoch
provMu.Unlock()
if start {
go refreshProvidersInBackground(ctx, epoch)
}
}
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)
}
unlock := lockAuth()
defer unlock()
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"`
Amount float64 `json:"amount"` // of Limit, in Unit: the window's own count, used
Limit float64 `json:"limit"`
Unit string `json:"unit"`
Span float64 `json:"span"` // seconds
Model string `json:"model"`
Models []string `json:"models"`
NotModels []string `json:"notModels"`
Aside bool `json:"aside"`
}
// Checkin is what came of an account's daily check-in, as the plugin's
// auth.checkin said: Outcome is one of claimed, done, ineligible,
// inactive, captcha and failed.
type Checkin struct {
Outcome string `json:"outcome"`
Credit float64 `json:"credit"`
Streak int `json:"streak"`
Message string `json:"message"`
}
// AccountCheckin asks the plugin to check account of provider in for the
// day.
func AccountCheckin(ctx context.Context, provider, account string) (Checkin, error) {
var c Checkin
err := Call(ctx, "checkin", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &c)
return c, err
}
// 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
}