From ca29a7baddc22ddba96e0d84dcf458abf2dc01fc Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 16:43:52 +0800 Subject: [PATCH] =?UTF-8?q?plugin:=20a=20plugin=20whose=20models=20hook=20?= =?UTF-8?q?throws=20keeps=20the=20list=20it=20told=20last,=20as=20a=20buil?= =?UTF-8?q?t-in=20does=20=E2=80=94=209abf441d=20kept=20the=20last=20list?= =?UTF-8?q?=20for=20a=20hook=20that=20gave=20its=20defaults=20back,=20but?= =?UTF-8?q?=20Cursor's,=20Grok's=20and=20Devin's=20hooks=20throw=20when=20?= =?UTF-8?q?their=20vendor=20fails,=20and=20an=20account=20past=20the=20fir?= =?UTF-8?q?st=20whose=20hook=20threw=20was=20taken=20to=20have=20all=20the?= =?UTF-8?q?=20provider's=20models;=20a=20vendor's=20hiccup=20still=20swapp?= =?UTF-8?q?ed=20the=20list=20for=20the=20plugin's=20defaults.=20A=20hook?= =?UTF-8?q?=20that=20throws=20now=20marks=20its=20listing=20fallen=20back,?= =?UTF-8?q?=20the=20provider's=20and=20each=20account's,=20and=20magpie=20?= =?UTF-8?q?keeps=20the=20list=20told=20last=20in=20its=20place.=20TestFall?= =?UTF-8?q?enBackListKeepsTheLastOne/throws=3D1=20fails=20without=20the=20?= =?UTF-8?q?change.=20go=20vet,=20go=20build=20-tags=20nogui=20(darwin,=20G?= =?UTF-8?q?OOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20go=20test=20-tag?= =?UTF-8?q?s=20nogui=20./...=20pass;=20internal/plugin=20passes=20on=20Win?= =?UTF-8?q?dows,=20internal/plugin=20and=20internal/provider=20on=20Linux.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/plugin/host.js | 12 ++++++++---- internal/plugin/plugin_listed_test.go | 10 +++++++++- internal/plugin/testdata/fake/index.js | 6 +++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/internal/plugin/host.js b/internal/plugin/host.js index 3ceead3a..3c466472 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -569,6 +569,9 @@ async function info(id, key, strict) { if (strict && !(e?.signIn === "expired" && all[k])) throw e const r = reach.getStore() if (r && e?.signIn === "expired") r.refused ??= e?.message ?? String(e) + // a hook that threw on its vendor's failure has no list to tell: + // magpie keeps the one it had, as for one that gave its defaults back + out.fellBack = true send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` }) } } @@ -615,8 +618,9 @@ async function providers() { const p = await info(id) // each account's own models, as the built-ins read each account's: a // plan may serve fewer, or others, than the first account's. One that - // can't be read is taken to have them all. - const own = await Promise.all(keys.slice(1).map((k) => info(id, k, true).catch(() => null))) + // can't be read is taken to have them all, or the ones it was last + // told to have, as a built-in account whose fetch failed keeps its own. + const own = await Promise.all(keys.slice(1).map((k) => info(id, k, true).catch(() => ({ failed: true })))) const models = { ...p.models } for (const q of own) for (const [k, m] of Object.entries(q?.models ?? {})) models[k] ??= m const ids = (q) => Object.values(q.models).filter((m) => m.status !== "deprecated").map((m) => m.id) @@ -639,8 +643,8 @@ async function providers() { 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: own.length === 0 ? undefined : i === 0 ? ids(p) : own[i - 1]?.models ? ids(own[i - 1]) : undefined, + fellBack: i === 0 ? !!p.fellBack : !!(own[i - 1]?.fellBack || own[i - 1]?.failed), })), 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 index b011c57c..a3f89ad5 100644 --- a/internal/plugin/plugin_listed_test.go +++ b/internal/plugin/plugin_listed_test.go @@ -15,9 +15,17 @@ import ( // 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. +// the vendor back, its list is told again. So too for a hook that throws +// (Cursor's, Grok's, Devin's) rather than give its defaults back. func TestFallenBackListKeepsTheLastOne(t *testing.T) { + for _, throws := range []string{"", "1"} { + t.Run("throws="+throws, func(t *testing.T) { fallenBack(t, throws) }) + } +} + +func fallenBack(t *testing.T, throws string) { sandbox(t) + t.Setenv("FAKE_MODELS_THROW", throws) var down atomic.Bool var name atomic.Value name.Store("extra") diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index a21556ff..cde0e43d 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -113,7 +113,11 @@ export const FakePlugin = async ({ client }) => ({ if (auth?.key === "few") return { "fake-1": p.models["fake-1"] } // $FAKE_MODELS: the vendor's list, whose answer names one more model if (process.env.FAKE_MODELS && auth) { - const r = await fetch(process.env.FAKE_MODELS).then((r) => r.text()).catch(() => "") + const r = await fetch(process.env.FAKE_MODELS).then((r) => r.text()).catch((e) => { + // $FAKE_MODELS_THROW: the hook throws, as Cursor's, Grok's and Devin's do + if (process.env.FAKE_MODELS_THROW) throw e + return "" + }) if (r) p.models["fake-" + r] = { ...p.models["fake-1"], id: "fake-" + r, name: r } } return p.models