plugin, gui: a plugin's account whose model list failed says why, rather than listing Auto alone (gnayiab on X) — with magpie in WSL, Cursor's plugin account offered only Auto: the plugin's models hook couldn't get the account's list and magpie, with no list kept from before, showed the plugin's defaults with nothing saying so, its editor even calling them "vendor list", and Refresh answered "1 models". The host now keeps why a models hook fell back (what it threw, else how its last fetch failed) for the provider and each account; plugin.Cached carries it with the accounts it reads again from the sign-ins; provider.ListError gives it for a plugin's account. The editor says "<provider> couldn't list its models: <why>" under the models (amber, no left border) and no longer calls the defaults the vendor's list, and Refresh fails with it. A list kept from before, or one that comes in, says nothing. en/zh/ja/de. Tried with the real Cursor plugin (0.1.9) in a sandbox: a key Cursor refuses shows "Cursor couldn't list its models: Cursor didn't take this API key" and Refresh says it; the owner's real account lists 72 models with nothing said, and Claude Code -p through the gateway ran a Bash tool on cursor/auto, composer-2.5 and claude-opus-5-thinking. The plugin itself, in WSL (magpie-desktop), listed 254 models and streamed chats with tools. TestFallenBackListSaysWhy (hook returns its defaults, throws, or hands back its own) fails without the change; plugin-list-error.test.cjs (Chromium and WebKit, en and zh) too; TestFallenBackListKeepsTheLastOne, account-models, plugin-signin and click-scroll pass. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 17:39:29 +08:00
1 parent a45b3e0986
commit 4788cf2bbd
8 files changed
+242 -18

No files matched your search

+5 -1
View File
@@ -8695,7 +8695,8 @@ function renderModels(p) {
if (noTest) { testAll.disabled = true; testAll.title = noTest; }
foot.append(testAll);
foot.append(rename);
if (p.fetched) foot.append(el("span", "hint", t("vendor list · {when}", { when: ago(p.fetched) })));
// a plugin's list that failed is its defaults, not the vendor's
if (p.fetched && !p.listError) foot.append(el("span", "hint", t("vendor list · {when}", { when: ago(p.fetched) })));
// a signed-in account's list, until the vendor gives one, is magpie's own
else if (p.models.length) foot.append(el("span", "hint", t(p.account ? "magpie's list · Refresh asks the vendor" : decideOnly(p) ? "Jev's names · Refresh asks the vendor" : "from models.dev · Refresh asks the vendor")));
if (p.fetched && !p.account) {
@@ -8710,6 +8711,9 @@ function renderModels(p) {
foot.append(forget);
}
box.append(foot);
// a plugin whose list failed shows its defaults (Cursor's Auto alone):
// why, rather than a short list that looks like the account's
if (p.listError) box.append(el("div", "hint model-hint warn list-error", t("{p} couldn't list its models: {error}", { p: p.name, error: p.listError })));
const why = el("div", "hint");
const [tk, cb] = tick(t("Only through routing groups"), !!draft.unlisted);
cb.onchange = () => { draft.unlisted = cb.checked; draw(); };
+3
View File
@@ -1290,6 +1290,7 @@ const I18N = {
"vendor list · {when}": "供应商列表 · {when}",
"from models.dev · Refresh asks the vendor": "来自 models.dev · 刷新可向供应商获取",
"magpie's list · Refresh asks the vendor": "magpie 内置列表 · 刷新可向供应商获取",
"{p} couldn't list its models: {error}": "{p} 没能获取模型列表:{error}",
"Names & levels": "名称与推理档位",
"Rename the models agents see, or offer fewer of their reasoning levels": "重命名 Agent 看到的模型,或只提供部分推理档位",
"Test models": "测试模型",
@@ -4496,6 +4497,7 @@ const I18N = {
"vendor list · {when}": "ベンダーの一覧 · {when}",
"from models.dev · Refresh asks the vendor": "models.dev より · 更新でベンダーに問い合わせ",
"magpie's list · Refresh asks the vendor": "magpie の一覧 · 更新でベンダーに問い合わせ",
"{p} couldn't list its models: {error}": "{p} のモデル一覧を取得できませんでした:{error}",
"Names & levels": "名前と推論レベル",
"Rename the models agents see, or offer fewer of their reasoning levels": "エージェントに見えるモデル名を変更するか、提供する推論レベルを絞ります",
"Test models": "モデルをテスト",
@@ -7697,6 +7699,7 @@ const I18N = {
"vendor list · {when}": "Anbieterliste · {when}",
"from models.dev · Refresh asks the vendor": "von models.dev · Aktualisieren fragt den Anbieter ab",
"magpie's list · Refresh asks the vendor": "Liste von magpie · Aktualisieren fragt den Anbieter ab",
"{p} couldn't list its models: {error}": "{p} konnte seine Modelle nicht abrufen: {error}",
"Names & levels": "Namen & Stufen",
"Rename the models agents see, or offer fewer of their reasoning levels": "Die Modelle, die Agenten sehen, umbenennen oder weniger ihrer Reasoning-Stufen anbieten",
"Test models": "Modelle testen",
+14 -10
View File
@@ -146,16 +146,19 @@ type providerJSON struct {
// Groups are the routing groups ("group/<id>") each of its models is
// in, by model id: what an unlisted one is still used through, and the
// editor names those in none
Groups map[string][]string `json:"groups,omitempty"`
Off bool `json:"off"` // switched off: kept, but agents get none of its models
Contexts map[string]int `json:"contexts,omitempty"` // the windows the user set, "*" for all its models
Outputs map[string]int `json:"outputs,omitempty"` // the reply limits the user set (provider.OutputsOf)
Compacts map[string]int `json:"compacts,omitempty"` // where Codex and Claude Code compact on its models (provider.CompactsOf)
Fetched *time.Time `json:"fetched,omitempty"` // when the list came from the vendor; the page says how long ago in its language
Agents []providerAgent `json:"agents"` // detected agents, current ones flagged
Sponsored bool `json:"sponsored"`
KeyList []provider.KeyInfo `json:"keyList"` // its keys, in the order requests try them
Account *accountJSON `json:"account,omitempty"` // a signed-in agent, see provider.Account
Groups map[string][]string `json:"groups,omitempty"`
Off bool `json:"off"` // switched off: kept, but agents get none of its models
Contexts map[string]int `json:"contexts,omitempty"` // the windows the user set, "*" for all its models
Outputs map[string]int `json:"outputs,omitempty"` // the reply limits the user set (provider.OutputsOf)
Compacts map[string]int `json:"compacts,omitempty"` // where Codex and Claude Code compact on its models (provider.CompactsOf)
Fetched *time.Time `json:"fetched,omitempty"` // when the list came from the vendor; the page says how long ago in its language
// ListError is why a plugin's account has only the plugin's defaults
// (provider.ListError): the editor says so under its models
ListError string `json:"listError,omitempty"`
Agents []providerAgent `json:"agents"` // detected agents, current ones flagged
Sponsored bool `json:"sponsored"`
KeyList []provider.KeyInfo `json:"keyList"` // its keys, in the order requests try them
Account *accountJSON `json:"account,omitempty"` // a signed-in agent, see provider.Account
// Move is where a built-in subscription stands with the community
// plugin that can run it (provider.Move): set for those that have one
Move *moveJSON `json:"move,omitempty"`
@@ -544,6 +547,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
if t, ok := p.Listed(); ok {
out.Fetched = &t
}
out.ListError = p.ListError()
for _, a := range agents {
pa := providerAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, Current: a.pid == p.ID, Model: a.model}
if a.inGroup {
@@ -0,0 +1,83 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A plugin's account whose model list failed has only the plugin's
// defaults (Cursor's Auto alone, gnayiab on X running magpie in WSL): its
// editor says why under the models, in the user's language, with no left
// border; an account whose list came in says nothing.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const said = "Cursor isn't signed in; sign in from magpie's Providers page or run `cursor-agent login`";
const cursor = (listError) => ({
id: "cursor", name: "Cursor", icon: "cursor", chat: "http://127.0.0.1/plugin/cursor/v1", responses: "", anthropic: "", catalog: "",
models: [{ id: "auto", name: "Auto", on: true }], agents: [], fallback: [], headers: {}, chosen: [],
key: { set: false, masked: "" }, keyList: [], ready: true, exposed: 1, fetched: new Date().toISOString(),
account: { agent: "cursor", agentName: "Cursor", agentIcon: "cursor", user: "me@example.com", builtin: "cursor" },
move: { package: "@magpie-community/opencode-cursor-auth", state: "plugin" },
...(listError ? { listError } : {}),
});
function server(lang, listError) {
const providers = { providers: [cursor(listError)], presets: [], excluded: [], gateway: { running: true, window: true } };
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(lang)},theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const [lang, want] of [["en", "Cursor couldn't list its models: " + said], ["zh", "Cursor 没能获取模型列表:" + said]]) {
test(`${engine} ${lang}: a plugin's failed list says why`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-plugin-list-error.png`) });
}
await browser.close();
});
await page.route("**/*", server(lang, said));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: "Cursor" }).click();
const hint = page.locator(".editor .list-error");
await hint.waitFor();
assert.equal(await hint.textContent(), want);
const look = await hint.evaluate((e) => { const s = getComputedStyle(e); return { left: s.borderLeftWidth, color: s.color, ground: getComputedStyle(document.body).color }; });
assert.equal(look.left, "0px", "no left border");
assert.notEqual(look.color, look.ground, "said as a warning");
assert.equal(await page.locator(".editor").getByText(/vendor list|供应商列表/).count(), 0, "its defaults aren't called the vendor's list");
assert.deepEqual(errors, []);
});
}
test(`${engine}: a plugin whose list came in says nothing`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
t.after(() => browser.close());
await page.route("**/*", server("en", ""));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: "Cursor" }).click();
await page.locator(".editor").waitFor();
await page.waitForTimeout(200);
assert.equal(await page.locator(".editor .list-error").count(), 0);
});
}
+9 -2
View File
@@ -379,9 +379,10 @@ function asked(input, init) {
if (!l) return sent(input, init)
l.tried = true
return sent(input, init).then(
(res) => ((l.lastOk = res.ok), res),
(res) => ((l.lastOk = res.ok), (l.said = res.ok ? "" : `HTTP ${res.status}`), res),
(e) => {
l.lastOk = false
l.said = e?.message ?? String(e)
throw e
},
)
@@ -911,6 +912,9 @@ async function info(id, key, strict) {
// says so, handing back a list of its own (Symbol.for("magpie.fellBack")
// on it: Command Code's Go table, ZCode's models)
out.fellBack = (next === given.models && l.tried && !l.lastOk) || next?.[Symbol.for("magpie.fellBack")] === true
// why, as far as the host saw it: the editor says so rather than
// show the plugin's short defaults as if they were the account's
if (out.fellBack) out.listError = l.said || "the plugin couldn't get its vendor's list"
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
@@ -926,6 +930,7 @@ async function info(id, key, strict) {
// 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
out.listError = e?.message ?? String(e)
send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` })
}
}
@@ -998,7 +1003,7 @@ async function providers({ proxies } = {}) {
// plan may serve fewer, or others, than the first account's. One that
// 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) => via.run(through(k), () => info(id, k, true)).catch(() => ({ failed: true }))))
const own = await Promise.all(keys.slice(1).map((k) => via.run(through(k), () => info(id, k, true)).catch((e) => ({ failed: true, listError: e?.message ?? String(e) }))))
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)
@@ -1017,6 +1022,7 @@ async function providers({ proxies } = {}) {
authType: first?.type ?? "",
accountId: whoOf(first),
fellBack: keys.length > 0 && !!p.fellBack,
listError: keys.length > 0 && p.fellBack ? p.listError ?? "" : "",
accounts: keys.map((k, i) => ({
key: k,
type: stored[k]?.type ?? "",
@@ -1024,6 +1030,7 @@ async function providers({ proxies } = {}) {
hint: hintOf(stored[k]),
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),
listError: (i === 0 ? p.fellBack && p.listError : own[i - 1]?.listError) || "",
})),
models: Object.values(models)
.filter((m) => m.status !== "deprecated")
+81
View File
@@ -0,0 +1,81 @@
package plugin
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// A plugin that never got its vendor's list says why, for the provider
// and each account: the editor shows it over the plugin's short defaults
// (gnayiab on X: Cursor in WSL listing Auto alone, with nothing saying
// its list had failed). Its list come in, nothing is said.
func TestFallenBackListSaysWhy(t *testing.T) {
for _, throws := range []string{"", "1", "own"} {
t.Run("throws="+throws, func(t *testing.T) { listSaysWhy(t, throws) })
}
}
func listSaysWhy(t *testing.T, throws string) {
sandbox(t)
t.Setenv("FAKE_MODELS_THROW", throws)
// no list told before this one, in memory or on disk
Restart()
provMu.Lock()
provCache = nil
provMu.Unlock()
var up atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !up.Load() {
c, _, _ := w.(http.Hijacker).Hijack()
c.Close()
return
}
w.Write([]byte("extra"))
}))
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)
}
}
ps, err := Providers(ctx)
if err != nil || len(ps) != 1 || len(ps[0].Accounts) != 2 {
t.Fatalf("Providers = %+v, %v", ps, err)
}
if !ps[0].FellBack || ps[0].ListError == "" {
t.Fatalf("the provider fell back saying %q (fellBack %v)", ps[0].ListError, ps[0].FellBack)
}
for _, a := range ps[0].Accounts {
if !a.FellBack || a.ListError == "" {
t.Fatalf("account %s fell back saying %q (fellBack %v)", a.Key, a.ListError, a.FellBack)
}
}
// and so as magpie reads it, its accounts read again from the sign-ins
for _, a := range Cached()[0].Accounts {
if !a.FellBack || a.ListError == "" {
t.Fatalf("Cached: account %s fell back saying %q (fellBack %v)", a.Key, a.ListError, a.FellBack)
}
}
up.Store(true)
if ps, err = Providers(ctx); err != nil || ps[0].FellBack || ps[0].ListError != "" {
t.Fatalf("with the list in, Providers = %+v, %v", ps, err)
}
for _, a := range ps[0].Accounts {
if a.FellBack || a.ListError != "" {
t.Fatalf("with the list in, account %s says %q", a.Key, a.ListError)
}
}
}
+13 -5
View File
@@ -91,6 +91,9 @@ type Provider struct {
// 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"`
@@ -112,8 +115,9 @@ 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"`
FellBack bool `json:"fellBack,omitempty"`
Models []string `json:"models,omitempty"`
FellBack bool `json:"fellBack,omitempty"`
ListError string `json:"listError,omitempty"`
}
var (
@@ -315,7 +319,11 @@ func keepListed(ps, last []Provider) []Provider {
continue
}
if p.FellBack && !l.FellBack && len(l.Models) > 0 {
ps[i].Models, ps[i].FellBack = l.Models, false
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 {
@@ -323,7 +331,7 @@ func keepListed(ps, last []Provider) []Provider {
}
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
ps[i].Accounts[j].Models, ps[i].Accounts[j].FellBack, ps[i].Accounts[j].ListError = b.Models, false, ""
}
}
}
@@ -437,7 +445,7 @@ func Cached() []Provider {
for i, a := range p.Accounts {
for _, w := range was {
if w.Key == a.Key {
p.Accounts[i].Models = w.Models
p.Accounts[i].Models, p.Accounts[i].FellBack, p.Accounts[i].ListError = w.Models, w.FellBack, w.ListError
}
}
}
+34
View File
@@ -222,6 +222,34 @@ func pluginAccountCatalog(pp plugin.Provider, key string) []catalog.Model {
return all
}
// PluginListError is why the plugin couldn't list the account at key's
// models, "" when it did or kept the list it had: what it shows instead
// is its short defaults (Cursor's Auto alone).
func PluginListError(pp plugin.Provider, key string) string {
for _, a := range pp.Accounts {
if a.Key == key {
if a.FellBack {
return a.ListError
}
return ""
}
}
return ""
}
// ListError is why a plugin's account lists only the plugin's defaults,
// "" for any other provider.
func (p Provider) ListError() string {
if !p.IsPlugin() {
return ""
}
pp := *p.Account.plugin
if cur, ok := PluginOf(p.ID); ok {
pp = cur
}
return PluginListError(pp, p.Account.pluginKey)
}
// pluginLists is whether a plugin's account serves model, as the plugin
// last told its list; one it told none of serves all of the provider's.
func (a *Account) pluginLists(model string) bool {
@@ -270,6 +298,12 @@ func pluginProvider(pp plugin.Provider, l pluginLogin) Provider {
}
for _, cur := range ps {
if cur.ID == pp.ID {
// a Refresh that got the plugin's short defaults back says
// why, rather than "1 models" (gnayiab on X: Cursor in WSL
// listing Auto alone)
if e := PluginListError(cur, acct.Key); e != "" {
return nil, fmt.Errorf("%s couldn't list its models: %s", name, e)
}
return catalog.Chat(pluginAccountCatalog(cur, acct.Key)), nil
}
}