plugin: a plugin whose model list can't be fetched keeps the one it told last, as a built-in does (ARNO on Discord: the Command Code plugin lists no Pixel Canary, the built-in does) — a built-in whose fetch fails keeps catalog.Live, the list it fetched last, while Command Code's, Kiro's, Qoder's, WorkBuddy's and Zed's plugins catch the failure and give their short defaults back, so a vendor's hiccup shrank the models. The host now sees a models hook that asked its vendor, had no answer from its last ask and handed back the list it was given, and marks that listing; magpie keeps the provider's and each account's last told list in its place, in memory and from plugin-providers.json after a restart, and the vendor back, its list is told again. TestFallenBackListKeepsTheLastOne fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; internal/plugin and internal/provider pass on Windows and Linux.

This commit is contained in:
yetone committed 2026-10-01 15:01:11 +08:00
1 parent db0a3344f4
commit 9abf441dfa
3 files changed
+153 -4

No files matched your search

+28 -3
View File
@@ -105,10 +105,27 @@ function sent(input, init) {
return p ? bunFetch(input, { ...init, proxy: p }) : bunFetch(input, init)
}
// listing is what a models hook's fetches came to: whether it asked any,
// and whether the last it asked answered
const listing = new AsyncLocalStorage()
function asked(input, init) {
const l = listing.getStore()
if (!l) return sent(input, init)
l.tried = true
return sent(input, init).then(
(res) => ((l.lastOk = res.ok), res),
(e) => {
l.lastOk = false
throw e
},
)
}
globalThis.fetch = Object.assign(function fetch(input, init) {
const r = reach.getStore()
if (!r) return sent(input, init)
return sent(input, init).then(
if (!r) return asked(input, init)
return asked(input, init).then(
(res) => ((r.reached = true), res),
(e) => {
r.failed ??= e
@@ -533,7 +550,13 @@ async function info(id, key, strict) {
const all = readAuth()
const k = key ?? accountsOf(all, id)[0] ?? id
try {
const next = await inScope(id, k, () => ph.models(JSON.parse(JSON.stringify(out)), { auth: all[k] }))
const given = JSON.parse(JSON.stringify(out))
const l = { tried: false, lastOk: false }
const next = await listing.run(l, () => inScope(id, k, () => ph.models(given, { auth: all[k] })))
// a hook that asked its vendor, got no list and gave back the one it
// was given fell back: magpie keeps the list it had, as a built-in
// whose fetch failed keeps the one it fetched last
out.fellBack = next === given.models && l.tried && !l.lastOk
out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }]))
} catch (e) {
// an error the models hook throws may say what it means for the
@@ -610,12 +633,14 @@ async function providers() {
signedIn: keys.length > 0,
authType: first?.type ?? "",
accountId: whoOf(first),
fellBack: keys.length > 0 && !!p.fellBack,
accounts: keys.map((k, i) => ({
key: k,
type: stored[k]?.type ?? "",
accountId: whoOf(stored[k]),
hint: hintOf(stored[k]),
models: own.length === 0 ? undefined : i === 0 ? ids(p) : own[i - 1] ? ids(own[i - 1]) : undefined,
fellBack: i === 0 ? !!p.fellBack : !!own[i - 1]?.fellBack,
})),
models: Object.values(models)
.filter((m) => m.status !== "deprecated")
+83
View File
@@ -0,0 +1,83 @@
package plugin
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"sync/atomic"
"testing"
"time"
)
// A plugin whose models hook can't reach its vendor and gives its
// defaults back keeps the list it told last, for the provider and each
// account, as a built-in whose fetch fails keeps the one it fetched last;
// the vendor back, its list is told again.
func TestFallenBackListKeepsTheLastOne(t *testing.T) {
sandbox(t)
var down atomic.Bool
var name atomic.Value
name.Store("extra")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down.Load() {
// the connection dropped, as a vendor gone away drops it
c, _, _ := w.(http.Hijacker).Hijack()
c.Close()
return
}
fmt.Fprint(w, name.Load())
}))
defer srv.Close()
t.Setenv("FAKE_MODELS", srv.URL+"/models")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
abs, _ := filepath.Abs("testdata/fake/index.js")
if _, err := Add(ctx, abs); err != nil {
t.Fatal(err)
}
for _, k := range []string{"k1", "k2"} {
if _, err := APIKey(ctx, "fakeco", 0, nil, k, NewAccount); err != nil {
t.Fatal(err)
}
}
has := func(ps []Provider, id string) bool {
t.Helper()
if len(ps) != 1 || len(ps[0].Accounts) != 2 {
t.Fatalf("Providers = %+v", ps)
}
in := slices.ContainsFunc(ps[0].Models, func(m Model) bool { return m.ID == id })
for _, a := range ps[0].Accounts {
if slices.Contains(a.Models, id) != in {
t.Fatalf("account %s's models %v, the provider's having %s: %v", a.Key, a.Models, id, in)
}
}
return in
}
ps, err := Providers(ctx)
if err != nil || !has(ps, "fake-extra") {
t.Fatalf("with the vendor up, Providers = %+v, %v", ps, err)
}
down.Store(true)
ps, err = Providers(ctx)
if err != nil || !has(ps, "fake-extra") || ps[0].FellBack {
t.Fatalf("with the vendor down, the list told last went: %+v, %v", ps, err)
}
// so after a restart, from the list kept on disk
Restart()
provMu.Lock()
provCache = nil
provMu.Unlock()
if ps, err = Providers(ctx); err != nil || !has(ps, "fake-extra") {
t.Fatalf("after a restart, Providers = %+v, %v", ps, err)
}
down.Store(false)
name.Store("newer")
if ps, err = Providers(ctx); err != nil || !has(ps, "fake-newer") || has(ps, "fake-extra") {
t.Fatalf("with the vendor back, Providers = %+v, %v", ps, err)
}
}
+42 -1
View File
@@ -80,6 +80,9 @@ type Provider struct {
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"`
@@ -95,7 +98,8 @@ type Account struct {
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"`
Models []string `json:"models,omitempty"`
FellBack bool `json:"fellBack,omitempty"`
}
var (
@@ -130,6 +134,7 @@ func Providers(ctx context.Context) ([]Provider, error) {
return nil, err
}
provMu.Lock()
ps = keepListed(ps, provCache)
ps = keepUnloaded(ps, provCache)
provCache, provGood = ps, true
provMu.Unlock()
@@ -139,6 +144,42 @@ func Providers(ctx context.Context) ([]Provider, error) {
return ps, nil
}
// 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