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:
3 files changed
+153
-4
No files matched your search
+28
-3
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user