diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index e0acad0b..f9f3afb3 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -7986,7 +7986,9 @@ function moveWhy(m, p) { case "offline": return t("magpie couldn't reach npm to install the plugin. Check the network or proxy, then try again."); case "install": return t("npm couldn't install the plugin: {line}", { line: a.line }); case "lapsed": return t("every {name} account needs signing in again. Sign one in above, then move.", { name: p.name }); - case "unserved": return t("the plugin doesn't serve {models}. Untick them under Models, or keep the built-in.", { models: a.models }); + case "unserved": return a.user + ? t("the plugin doesn't serve {models} for {user}, though the built-in does. Untick them under Models, or keep the built-in.", { models: a.models, user: a.user }) + : t("the plugin doesn't serve {models}. Untick them under Models, or keep the built-in.", { models: a.models }); case "account": return t("{user} doesn't work through the plugin: {error}", { user: a.user, error: a.error }); } return m.error || ""; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ad6def81..e4e6df58 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -479,6 +479,7 @@ const I18N = { "every {name} account needs signing in again. Sign one in above, then move.": "所有 {name} 账号都需要重新登录。先在上方登录一个,再迁移。", "every {name} account needs signing in again. Sign one in under Providers, then move.": "所有 {name} 账号都需要重新登录。先在「服务商」里登录一个,再迁移。", "the plugin doesn't serve {models}. Untick them under Models, or keep the built-in.": "插件不提供 {models}。可在「模型」里取消勾选,或继续使用内置。", + "the plugin doesn't serve {models} for {user}, though the built-in does. Untick them under Models, or keep the built-in.": "插件没有为 {user} 提供 {models}(内置提供)。可在「模型」里取消勾选,或继续使用内置。", "{user} doesn't work through the plugin: {error}": "{user} 无法通过插件使用:{error}", "{name} now runs on its plugin — {accounts}, {models}. Use the built-in again from here any time.": "{name} 现在由插件运行——{accounts},{models}。随时可以在这里改回内置。", "{name} now runs on its plugin — {accounts}, {models}. Its editor in Providers moves it back.": "{name} 现在由插件运行——{accounts},{models}。在「服务商」里编辑它即可改回。", diff --git a/internal/gui/tests/plugin-move-unserved.test.cjs b/internal/gui/tests/plugin-move-unserved.test.cjs new file mode 100644 index 00000000..22770d26 --- /dev/null +++ b/internal/gui/tests/plugin-move-unserved.test.cjs @@ -0,0 +1,101 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A move held back by a model the plugin doesn't offer says for which +// account (and its plan) the built-in serves it, so a reader can tell a +// model really lost from one their plan never had; a why with no account +// (an older magpie's) still reads as before. In English and Chinese; the +// API is faked here. +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 pkg = "@magpie-community/opencode-zed-auth"; + +function serve(lang, posts, why) { + let move = { package: pkg, state: "" }; + const payload = () => { + const onPlugin = move.state === "plugin"; + const zed = { + id: "zed", name: "Zed", icon: "zed", chat: onPlugin ? "plugin://zed/v1" : "", responses: "", anthropic: onPlugin ? "plugin://zed" : "", catalog: "", + // enough models that the editor scrolls to reach Runs on + models: Array.from({ length: 30 }, (_, i) => ({ id: "m" + i, name: "Model " + i, on: i === 0 })), + agents: [], fallback: [], headers: {}, keyList: [], key: {}, + account: { agent: "zed", agentName: "Zed", user: "ada", logins: [{ user: "ada", active: true, on: true, own: true }, { user: "bob", on: true }] }, + move, + }; + return { + providers: [zed], presets: [], excluded: [], gateway: { running: true, window: true }, + plugins: onPlugin ? [{ id: "zed", pid: "zed", name: "Zed", icon: "zed", spec: pkg, signedIn: true, models: 1, methods: [] }] : [], + onPlugins: onPlugin ? ["zed"] : [], + }; + }; + 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:"${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({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/providers") return json(payload()); + if (url.pathname === "/api/groups") return json({ groups: [] }); + // the page asks for the market in parts (#488) + if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") { + const m = ({ listings: [{ package: pkg, name: "Zed", icon: "zed", providers: ["zed"], community: true, replaces: "zed", summary: { en: "Zed", zh: "Zed" }, npm: { version: "0.1.0" } }], + state: { bun: true, bunVersion: "1.3.0", plugins: [], movable: move.state === "plugin" ? [] : [{ id: "zed", name: "Zed", package: pkg, accounts: 2 }] } }); + return json(url.pathname === "/api/plugins" ? m.state : url.pathname === "/api/plugins/listings" ? { listings: m.listings } : m); + } + if (url.pathname === "/api/provider/move" || url.pathname === "/api/provider/moveback") { + posts.push({ path: url.pathname, body: route.request().postDataJSON() }); + await new Promise((r) => setTimeout(r, 400)); // long enough to read the button + // the plugin doesn't offer a model the built-in serves on ada + if (url.pathname.endsWith("/move")) { + move = { package: pkg, state: "failed", error: "unserved", why }; + return route.fulfill({ status: 400, json: { error: move.error, why } }); + } + move = { package: pkg, state: url.pathname.endsWith("moveback") ? "back" : "plugin" }; + return json(payload()); + } + 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)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const L = { + en: { look: "Review the move", + withUser: "It stays built-in: the plugin doesn't serve Model 0 for ada (Zed Pro), though the built-in does. Untick them under Models, or keep the built-in.", + bare: "It stays built-in: the plugin doesn't serve Model 0. Untick them under Models, or keep the built-in." }, + zh: { look: "查看迁移", + withUser: "仍使用内置:插件没有为 ada (Zed Pro) 提供 Model 0(内置提供)。可在「模型」里取消勾选,或继续使用内置。", + bare: "仍使用内置:插件不提供 Model 0。可在「模型」里取消勾选,或继续使用内置。" }, +}; + +const launch = (engine) => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + for (const [kind, why] of [["withUser", { code: "unserved", args: { models: "Model 0", user: "ada (Zed Pro)" } }], ["bare", { code: "unserved", args: { models: "Model 0" } }]]) { + test(`${engine} ${lang}: a move short of a model says it (${kind})`, async (t) => { + const w = L[lang]; + const browser = await launch(engine); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts, why)); + await page.goto("http://magpie.test/?view=providers"); + const line = page.locator("#movable"); + await line.locator("button", { hasText: w.look }).click(); + const ed = page.locator("#modal .editor"); + await ed.locator(".move button").click(); + const said = ed.locator(".move-why"); + await said.waitFor({ state: "visible" }); + assert.equal(await said.innerText(), w[kind]); + assert.deepEqual(errors, []); + }); + } + } +} diff --git a/internal/provider/account.go b/internal/provider/account.go index a0a41b7c..29b9e854 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -83,7 +83,8 @@ type Account struct { // reach, factory.go; another model for Copilot's Auto, copilot_refused.go). retry func(ctx context.Context, model string, status int, body []byte) bool // unusable is set on a Copilot account: whether a model its list offers - // is one the account was refused (copilot_refused.go). + // is one the account was refused (copilot_refused.go); and on a ZCode + // account on the Start Plan: whether it is one only the Coding Plan has. unusable func(model string) bool // explain adds what the user can do about a refusal the account's // backend answered, "" when there is nothing to add (factory.go). diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 42905fc9..210a0eb9 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -87,7 +87,8 @@ type Migration struct { // offline magpie couldn't reach npm (or Bun's download) to install the plugin // install npm couldn't install it: line // lapsed every account of name needs signing in again -// unserved the plugin doesn't serve models (their names) +// unserved the plugin doesn't serve models (their names) for user (with +// its plan), though the built-in does // account user doesn't work through the plugin: error type MoveWhy struct { Code string `json:"code"` @@ -222,6 +223,11 @@ type mover struct { // served is whether the plugin, listing listed, still serves model, one // of the built-in's picks it doesn't list; nil for none. served func(model string, listed []string) bool + // builtin is whether the built-in served model on account a, by the + // plan a is on; nil for every model on every account. One it didn't + // (a ZCode Start Plan account's GLM-5.3) is no loss when the plugin + // doesn't list it either. + builtin func(ctx context.Context, a Moving, model string) bool } // movers are the deprecated built-ins and their plugins, by id. Once one @@ -598,10 +604,15 @@ func move(ctx context.Context, id string, mv *mover) (err error) { } if !tried { if missing := slices.DeleteFunc(slices.Clone(inUse), func(m string) bool { - return slices.Contains(c.Models, m) || mv.served != nil && mv.served(m, c.Models) + return slices.Contains(c.Models, m) || mv.served != nil && mv.served(m, c.Models) || + mv.builtin != nil && !mv.builtin(ctx, a, m) }); len(missing) > 0 { names := strings.Join(modelNames(id, missing), ", ") - return &moveError{MoveWhy{Code: "unserved", Args: map[string]string{"models": names}}, fmt.Sprintf("the plugin doesn't serve %s. Untick them under Models, or keep the built-in.", names), nil} + who := a.User + if a.Plan != "" { + who += " (" + a.Plan + ")" + } + return &moveError{MoveWhy{Code: "unserved", Args: map[string]string{"models": names, "user": who}}, fmt.Sprintf("the plugin doesn't serve %s for %s, though the built-in does. Untick them under Models, or keep the built-in.", names, who), nil} } tried = true } diff --git a/internal/provider/migrate_test.go b/internal/provider/migrate_test.go index b3eb7c50..c6ce375b 100644 --- a/internal/provider/migrate_test.go +++ b/internal/provider/migrate_test.go @@ -160,6 +160,28 @@ func TestMoveToPlugin(t *testing.T) { t.Fatalf("Move with a served variant and a model the plugin lacks = %v", err) } movers["fakeco"].served = nil + // one the built-in never served on the account either (a ZCode Start + // Plan account's GLM-5.3) is no loss and isn't named; one it did is, + // with the account and its plan + movers["fakeco"].builtin = func(_ context.Context, a Moving, m string) bool { return a.User != "a@fake" || m != "never-model" } + reset(func() savedLogin { l := fakeLogin("a@fake", "r-a", false, true); l.Plan = "Fake Start"; return l }()) + inUse = []string{"fake-1", "never-model"} + if err := Move(ctx, "fakeco"); err != nil { + t.Fatalf("Move with a model the built-in didn't serve either = %v", err) + } + if err := MoveBack(ctx, "fakeco"); err != nil { + t.Fatal(err) + } + reset(func() savedLogin { l := fakeLogin("a@fake", "r-a", false, true); l.Plan = "Fake Start"; return l }()) + inUse = []string{"fake-1", "never-model", "gone-model"} + err = Move(ctx, "fakeco") + if err == nil || strings.Contains(err.Error(), "never-model") || !strings.Contains(err.Error(), "gone-model for a@fake (Fake Start)") { + t.Fatalf("Move with a model the built-in served on the account = %v", err) + } + if w := WhyOf(err); w == nil || w.Code != "unserved" || w.Args["user"] != "a@fake (Fake Start)" || w.Args["models"] != "gone-model" { + t.Fatalf("its why: %+v", w) + } + movers["fakeco"].builtin = nil inUse = []string{"fake-1", "fake-claude"} // an account signed in through the plugin already gets back what it had diff --git a/internal/provider/migrate_zcode.go b/internal/provider/migrate_zcode.go index 6b7b72b4..ea485d37 100644 --- a/internal/provider/migrate_zcode.go +++ b/internal/provider/migrate_zcode.go @@ -1,6 +1,7 @@ package provider import ( + "context" "encoding/json" "errors" "strings" @@ -22,6 +23,18 @@ func init() { pkg: "@magpie-community/opencode-zcode-auth", min: "0.1.5", // a failure's status and its sign-in mark as the built-in's; MCP quota set aside as the built-in's agents: []string{"zcode"}, + // a Start Plan account was never served GLM-5.3, by the built-in + // or by ZCode, and the plugin lists it no more than they do; its + // plan as its requests found it, else as it showed it, nothing + // asked of Z.ai + builtin: func(_ context.Context, a Moving, model string) bool { + for _, l := range zcodeLogins() { + if strings.EqualFold(l.User, a.User) { + return !zcodeOnStartAs(nil, l.key, l.Plan) || zcodeStartServes()(model) + } + } + return true + }, out: func() ([]Moving, error) { var out []Moving for _, l := range zcodeLogins() { diff --git a/internal/provider/models.go b/internal/provider/models.go index a3f577e7..0b664982 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -536,6 +536,10 @@ func (p Provider) Exposed() []catalog.Model { // not in a Student plan's) or that it was refused is left out; // with none left, as if none were picked. picks = slices.DeleteFunc(slices.Clone(picks), func(id string) bool { _, ok := byID[id]; return !ok }) + } else if p.Account != nil && p.Account.unusable != nil { + // with no list fetched, a pick the account can't be served (a + // ZCode Start Plan account's GLM-5.3) is left out all the same + picks = slices.DeleteFunc(slices.Clone(picks), p.Account.unusable) } if len(picks) > 0 { return pick(picks) diff --git a/internal/provider/zcode.go b/internal/provider/zcode.go index c46f1053..282f576c 100644 --- a/internal/provider/zcode.go +++ b/internal/provider/zcode.go @@ -50,6 +50,7 @@ import ( "runtime" "slices" "strings" + "sync" "time" "github.com/yetone/magpie/internal/catalog" @@ -340,17 +341,24 @@ func zcodeProvider(who, plan string, k zcodeKey) Provider { } acct.clientFor = zcodeStartClientFor acct.explain = func(status int, body []byte) string { - if zcodeOnStart(nil, k) { + if zcodeOnStartAs(nil, k, plan) { return zcodeStartExplain(status, body) } return "" } acct.models = func() []catalog.Model { - if zcodeOnStart(nil, k) { + if zcodeOnStartAs(nil, k, plan) { return zcodeStartModels } return zcodeModels } + // on the Start Plan, a model only the Coding Plan has (GLM-5.3) is + // neither listed nor picked, though the list fetched last (another + // account's, or this one's before its plan was found) has it + startServes := sync.OnceValue(zcodeStartServes) + acct.unusable = func(model string) bool { + return zcodeOnStartAs(nil, k, plan) && !startServes()(model) + } acct.fetch = func(ctx context.Context) ([]catalog.Model, error) { b := base(zcodeOnStart(ctx, k)) ms, err := zcodeFetchModels(ctx, b) @@ -359,7 +367,7 @@ func zcodeProvider(who, plan string, k zcodeKey) Provider { } return ms, catalog.SaveLive("zcode", b, ms) } - return Provider{ID: "zcode", Name: "ZCode", Icon: "zcode", Anthropic: base(zcodeOnStart(nil, k)), Website: "https://zcode.z.ai", Account: acct} + return Provider{ID: "zcode", Name: "ZCode", Icon: "zcode", Anthropic: base(zcodeOnStartAs(nil, k, plan)), Website: "https://zcode.z.ai", Account: acct} } // ---- allowance ---------------------------------------------------------------- diff --git a/internal/provider/zcode_start.go b/internal/provider/zcode_start.go index e636dce7..9ca8807c 100644 --- a/internal/provider/zcode_start.go +++ b/internal/provider/zcode_start.go @@ -36,6 +36,7 @@ import ( "net/url" "os" "regexp" + "slices" "strconv" "strings" "sync" @@ -60,6 +61,28 @@ var zcodeStartModels = func() []catalog.Model { return out }() +// zcodeStartNamed is whether a plan's name, as an account showed it, is +// ZCode's Start Plan (zcodeBalance.active's name for it). +func zcodeStartNamed(plan string) bool { + p := strings.ToLower(plan) + return strings.Contains(p, "start plan") || strings.Contains(p, "start-plan") || strings.Contains(plan, "体验") +} + +// zcodeStartServes is the Start Plan's models, as ZCode's config on this +// machine lists them for account:zai-start-plan (zcodeStartModels without +// one): whether it serves model. GLM-5.3 is the Coding Plan's alone. +func zcodeStartServes() func(model string) bool { + ms := zcodeStartModels + if b, ok := zcodeLocalBuiltin(); ok { + if l := b.models("account:zai-start-plan"); len(l) > 0 { + ms = l + } + } + return func(model string) bool { + return slices.ContainsFunc(ms, func(m catalog.Model) bool { return strings.EqualFold(m.ID, model) }) + } +} + // ZCodeStartBlockedHint is what the Start Plan's "request has been blocked // due to unusual activity" (HTTP 405, code 3012 "method not allowed") // means (#425): zcode.z.ai looks at what a request carries and turns away @@ -96,7 +119,14 @@ type zcodeRoute struct { // zcodeOnStart says whether k's requests go to the Start Plan, asking // when the last answer is old; with ctx nil it only says what was found // last (the Coding Plan when nothing was). -func zcodeOnStart(ctx context.Context, k zcodeKey) bool { +func zcodeOnStart(ctx context.Context, k zcodeKey) bool { return zcodeOnStartAs(ctx, k, "") } + +// zcodeOnStartAs is zcodeOnStart for an account that showed plan: with +// ctx nil and nothing found yet, the plan's name says it. An account with +// a key and no Coding Plan (the key made, the plan not bought) is on the +// Start Plan, and was shown the Coding Plan's models, GLM-5.3 among them, +// until a request found where it goes. +func zcodeOnStartAs(ctx context.Context, k zcodeKey, plan string) bool { if k.JWT == "" || k.team() { // a team's seat is on the team's plan (zcode_team.go) return false } @@ -107,6 +137,9 @@ func zcodeOnStart(ctx context.Context, k zcodeKey) bool { zcodeRoutes.Lock() r, ok := zcodeRoutes.m[id] zcodeRoutes.Unlock() + if ctx == nil && !ok { + return zcodeStartNamed(plan) + } if ctx == nil || ok && time.Since(r.at) < r.ttl { return r.start } diff --git a/internal/provider/zcode_startmodels_test.go b/internal/provider/zcode_startmodels_test.go new file mode 100644 index 00000000..1fcf0087 --- /dev/null +++ b/internal/provider/zcode_startmodels_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "context" + "encoding/json" + "slices" + "testing" + "time" + + "github.com/yetone/magpie/internal/catalog" +) + +func zcodeIDs(ms []catalog.Model) []string { + var out []string + for _, m := range ms { + out = append(out, m.ID) + } + return out +} + +// 悠悠哥's move to the plugin was held back by GLM-5.3 on a Start Plan +// (体验套餐) account: a Z.ai account with a key made and no Coding Plan +// bought, its requests going to the Start Plan, was shown the Coding +// Plan's models until a request found where it goes, and the list fetched +// last (the Coding Plan's) after that, so GLM-5.3 was listed and could be +// ticked, though neither ZCode nor the built-in serves it there. Now such an +// account neither lists nor exposes it, picked or not; a Coding Plan one +// still does. And the move doesn't count it as one the plugin drops. +func TestZCodeStartPlanHasNoGLM53(t *testing.T) { + signIn(t) + old := zcodeBuiltinFiles + zcodeBuiltinFiles = func() []string { return nil } // not this machine's ZCode + t.Cleanup(func() { zcodeBuiltinFiles = old }) + zcodeRoutes.Lock() + zcodeRoutes.m = map[string]zcodeRoute{} + zcodeRoutes.Unlock() + jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour)) + start := zcodeKey{Key: "made.key", Base: ZCodeZaiBase, JWT: jwt} + saveLogins(t, savedLogin{Agent: "zcode", User: "trial@x", Plan: "Start Plan", First: true, On: true, Auth: json.RawMessage(jsonText(start))}) + + has53 := func(ms []catalog.Model) bool { return slices.Contains(zcodeIDs(ms), "GLM-5.3") } + p, ok := zcodeAccount() + if !ok { + t.Fatal("no ZCode account") + } + // before any request: its plan's name says the Start Plan + if ms := p.Available(); has53(ms) || len(ms) == 0 { + t.Fatalf("a Start Plan account before its route is found lists %v", zcodeIDs(ms)) + } + p.Models = []string{"GLM-5.3", "GLM-5.2"} + if ms := p.Exposed(); has53(ms) || !slices.Contains(zcodeIDs(ms), "GLM-5.2") { + t.Fatalf("a Start Plan account with GLM-5.3 ticked exposes %v", zcodeIDs(ms)) + } + // with the Coding Plan's list fetched last + if err := catalog.SaveLive("zcode", ZCodeZaiBase, zcodeModels); err != nil { + t.Fatal(err) + } + p.Models = nil + if ms := p.Available(); has53(ms) || len(ms) == 0 { + t.Fatalf("a Start Plan account under the Coding Plan's list lists %v", zcodeIDs(ms)) + } + p.Models = []string{"GLM-5.3", "GLM-5.2"} + if ms := p.Exposed(); has53(ms) || !slices.Contains(zcodeIDs(ms), "GLM-5.2") { + t.Fatalf("a Start Plan account under the Coding Plan's list exposes %v", zcodeIDs(ms)) + } + + // the move: GLM-5.3 the built-in didn't serve on it, GLM-5.2 it did + a := Moving{User: "trial@x", Plan: "Start Plan"} + if b := movers["zcode"].builtin; b(context.Background(), a, "GLM-5.3") || !b(context.Background(), a, "GLM-5.2") { + t.Fatal("the move counts GLM-5.3 as served on a Start Plan account") + } + + // a Coding Plan account still has it + coding := zcodeKey{Key: "plan.key", Base: ZCodeZaiBase, JWT: jwt} + saveLogins(t, savedLogin{Agent: "zcode", User: "pro@x", Plan: "GLM Coding Pro", First: true, On: true, Auth: json.RawMessage(jsonText(coding))}) + p, _ = zcodeAccount() + p.Models = []string{"GLM-5.3"} + if !has53(p.Available()) || !has53(p.Exposed()) { + t.Fatalf("a Coding Plan account lists %v", zcodeIDs(p.Available())) + } + if !movers["zcode"].builtin(context.Background(), Moving{User: "pro@x", Plan: "GLM Coding Pro"}, "GLM-5.3") { + t.Fatal("the move counts GLM-5.3 as not served on a Coding Plan account") + } +}