From 9abf441dfaf21f4f5231fa6fdd475f18b0239dbc Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 15:01:07 +0800 Subject: [PATCH] =?UTF-8?q?plugin:=20a=20plugin=20whose=20model=20list=20c?= =?UTF-8?q?an't=20be=20fetched=20keeps=20the=20one=20it=20told=20last,=20a?= =?UTF-8?q?s=20a=20built-in=20does=20(ARNO=20on=20Discord:=20the=20Command?= =?UTF-8?q?=20Code=20plugin=20lists=20no=20Pixel=20Canary,=20the=20built-i?= =?UTF-8?q?n=20does)=20=E2=80=94=20a=20built-in=20whose=20fetch=20fails=20?= =?UTF-8?q?keeps=20catalog.Live,=20the=20list=20it=20fetched=20last,=20whi?= =?UTF-8?q?le=20Command=20Code's,=20Kiro's,=20Qoder's,=20WorkBuddy's=20and?= =?UTF-8?q?=20Zed's=20plugins=20catch=20the=20failure=20and=20give=20their?= =?UTF-8?q?=20short=20defaults=20back,=20so=20a=20vendor's=20hiccup=20shra?= =?UTF-8?q?nk=20the=20models.=20The=20host=20now=20sees=20a=20models=20hoo?= =?UTF-8?q?k=20that=20asked=20its=20vendor,=20had=20no=20answer=20from=20i?= =?UTF-8?q?ts=20last=20ask=20and=20handed=20back=20the=20list=20it=20was?= =?UTF-8?q?=20given,=20and=20marks=20that=20listing;=20magpie=20keeps=20th?= =?UTF-8?q?e=20provider's=20and=20each=20account's=20last=20told=20list=20?= =?UTF-8?q?in=20its=20place,=20in=20memory=20and=20from=20plugin-providers?= =?UTF-8?q?.json=20after=20a=20restart,=20and=20the=20vendor=20back,=20its?= =?UTF-8?q?=20list=20is=20told=20again.=20TestFallenBackListKeepsTheLastOn?= =?UTF-8?q?e=20fails=20without=20the=20change.=20go=20vet,=20go=20build=20?= =?UTF-8?q?-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go?= =?UTF-8?q?=20build,=20go=20test=20-tags=20nogui=20./...=20pass;=20interna?= =?UTF-8?q?l/plugin=20and=20internal/provider=20pass=20on=20Windows=20and?= =?UTF-8?q?=20Linux.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/plugin/host.js | 31 +++++++++- internal/plugin/plugin_listed_test.go | 83 +++++++++++++++++++++++++++ internal/plugin/providers.go | 43 +++++++++++++- 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 internal/plugin/plugin_listed_test.go diff --git a/internal/plugin/host.js b/internal/plugin/host.js index 442ba6b1..3ceead3a 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -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") diff --git a/internal/plugin/plugin_listed_test.go b/internal/plugin/plugin_listed_test.go new file mode 100644 index 00000000..b011c57c --- /dev/null +++ b/internal/plugin/plugin_listed_test.go @@ -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) + } +} diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 5a4c506a..c08e3670 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -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