diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 47149bca..3f7b8a8c 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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(); }; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index afb5fa0d..57b150f4 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 3af45011..b3d48afd 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -146,16 +146,19 @@ type providerJSON struct { // Groups are the routing groups ("group/") 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 { diff --git a/internal/gui/tests/plugin-list-error.test.cjs b/internal/gui/tests/plugin-list-error.test.cjs new file mode 100644 index 00000000..90a56c70 --- /dev/null +++ b/internal/gui/tests/plugin-list-error.test.cjs @@ -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); + }); +} diff --git a/internal/plugin/host.js b/internal/plugin/host.js index f4e631b5..a413a954 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -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") diff --git a/internal/plugin/plugin_list_error_test.go b/internal/plugin/plugin_list_error_test.go new file mode 100644 index 00000000..cb869823 --- /dev/null +++ b/internal/plugin/plugin_list_error_test.go @@ -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) + } + } +} diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 62be5e70..b41e7886 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -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 } } } diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index 9646bce6..a6f92583 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -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 } }