diff --git a/accounts_cli.go b/accounts_cli.go index ae848c52..db60a3dc 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -24,7 +24,7 @@ import ( // — the subscriptions magpie remembers, how much of each one's allowance is // used, and switching the agent between them. func accountsCmd(args []string) error { - const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo] [--json] | magpie accounts add | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget | magpie accounts project " + const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|] [--json] | magpie accounts add > | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget > | magpie accounts project " agentID := func(s string) (string, error) { switch strings.ToLower(s) { case "claude", "cc": @@ -42,7 +42,11 @@ func accountsCmd(args []string) error { case "mimo", "mimo-app", "xiaomi-mimo": return provider.MiMoID, nil } - return "", fmt.Errorf("%q: only Claude Code, Codex, Gemini CLI, Antigravity, Zed, Factory and Xiaomi MiMo accounts can be added and switched\n%s", s, usage) + // a plugin's subscription, by its provider's id or name, as a built-in's + if pp, err := pluginProvider(context.Background(), s); err == nil { + return provider.PluginID(pp.ID), nil + } + return "", fmt.Errorf("%q: only Claude Code, Codex, Gemini CLI, Antigravity, Zed, Factory, Xiaomi MiMo and plugins' accounts can be added and switched\n%s", s, usage) } if len(args) > 1 && args[1] == "project" { if len(args) != 5 { @@ -101,7 +105,7 @@ func accountsCmd(args []string) error { if err := provider.SwitchLogin(id, args[3]); err != nil { return err } - if id == "gemini" || id == "antigravity" { + if _, plug := provider.PluginOf(id); plug || id == "gemini" || id == "antigravity" { fmt.Println(green.Render("✓"), "magpie now uses", args[3], "for", id) return nil } @@ -277,7 +281,7 @@ func addAccount(agentID string) error { return fmt.Errorf("sign-in canceled") } } - if provider.Moved(agentID) { + if _, plug := provider.PluginOf(agentID); plug || provider.Moved(agentID) { return pluginLogin(context.Background(), agentID, "") } st, err := provider.StartSignIn(agentID) diff --git a/accounts_plugin_cli_test.go b/accounts_plugin_cli_test.go new file mode 100644 index 00000000..81a81544 --- /dev/null +++ b/accounts_plugin_cli_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "encoding/json" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/provider" +) + +// magpie accounts lists, switches and forgets a plugin's accounts by its +// provider's id, as a built-in subscription's. +func TestAccountsOfAPlugin(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")) + t.Setenv("MAGPIE_BUN", bun) + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("internal/plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + for _, key := range []string{"sk-one-key-1111", "sk-two-key-2222"} { + if _, err := provider.PluginAPIKey(ctx, "fakeco", 0, nil, key); err != nil { + t.Fatal(err) + } + } + list := func() []accountRow { + t.Helper() + out, err := said(t, func() error { return accountsCmd([]string{"accounts", "fakeco", "--json"}) }) + if err != nil { + t.Fatal(err) + } + var rows []accountRow + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("%v: %s", err, out) + } + return rows + } + rows := list() + if len(rows) != 2 || !rows[0].Active || rows[1].Active || rows[0].User != "API key …1111" || rows[1].User != "API key …2222" { + t.Fatalf("magpie accounts fakeco = %+v", rows) + } + second := rows[1].User + if _, err := said(t, func() error { return accountsCmd([]string{"accounts", "switch", "fakeco", second}) }); err != nil { + t.Fatal(err) + } + if rows := list(); len(rows) != 2 || rows[0].User != second || !rows[0].Active { + t.Fatalf("after switching to %s: %+v", second, rows) + } + // the one in use isn't forgotten, as a built-in's isn't; the other is + if _, err := said(t, func() error { return accountsCmd([]string{"accounts", "forget", "fakeco", second}) }); err == nil { + t.Fatal("the account in use was forgotten") + } + first := rows[0].User + if _, err := said(t, func() error { return accountsCmd([]string{"accounts", "forget", "fakeco", first}) }); err != nil { + t.Fatal(err) + } + if rows := list(); len(rows) != 1 || rows[0].User != second || !rows[0].Active { + t.Fatalf("after forgetting %s: %+v", first, rows) + } + if _, err := said(t, func() error { return accountsCmd([]string{"accounts", "switch", "nosuch", "a"}) }); err == nil || !strings.Contains(err.Error(), "can be added and switched") { + t.Fatalf("an unknown one: %v", err) + } +} diff --git a/internal/gateway/plugin_fast_test.go b/internal/gateway/plugin_fast_test.go new file mode 100644 index 00000000..fc320bb3 --- /dev/null +++ b/internal/gateway/plugin_fast_test.go @@ -0,0 +1,51 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// Cursor's plugin has fast mode where the built-in Cursor has it, for a +// model with a -fast one: a group's member is saved :fast and sent fast, +// and one with no -fast one is refused, as the built-in's is. +func TestCursorPluginGroupFast(t *testing.T) { + t.Setenv("FAKE_FAST", "1") + var mu sync.Mutex + var tiers []any + pid := besideFake(t, "cursor", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var q map[string]any + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &q) + mu.Lock() + tiers = append(tiers, q["service_tier"]) + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse(`data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`, `data: [DONE]`)) + })) + p, err := provider.Find(pid) + if err != nil { + t.Fatal(err) + } + if !provider.CanFast(*p, "fake-1") || provider.CanFast(*p, "fake-1-fast") || provider.CanFast(*p, "fake-claude") { + t.Fatal("the Cursor plugin's fast models aren't the ones with a -fast one") + } + if err := provider.SaveGroup(provider.Group{ID: "g", Members: []string{pid + "/fake-claude:fast"}}); err == nil || !strings.Contains(err.Error(), "no fast mode") { + t.Fatalf("fake-claude saved fast: %v", err) + } + saveFastGroup(t, []string{pid + "/fake-1:fast"}, nil) + r := postProto(t, New(), "/v1/chat/completions", `{"model":"group/f","messages":[{"role":"user","content":"hi"}]}`) + if len(r.Tries) != 1 || !r.Tries[0].Fast { + t.Fatalf("tries %+v", r.Tries) + } + mu.Lock() + defer mu.Unlock() + if len(tiers) != 1 || tiers[0] != "priority" { + t.Fatalf("Cursor's plugin was told %v", tiers) + } +} diff --git a/internal/gateway/plugin_grok_parity_test.go b/internal/gateway/plugin_grok_parity_test.go index 3bcc9f93..58b7ccf6 100644 --- a/internal/gateway/plugin_grok_parity_test.go +++ b/internal/gateway/plugin_grok_parity_test.go @@ -164,3 +164,16 @@ func TestPluginBesideBuiltinCountsByEstimate(t *testing.T) { t.Fatalf("the vendor was asked to count: %v, %d", up.asked(), n) } } + +// Grok's plugin beside the built-in Grok (grok-plugin) searches by itself +// as the built-in does, on Responses only. +func TestGrokPluginSearchesItself(t *testing.T) { + pid := besideFake(t, "grok", http.NotFoundHandler()) + p, err := provider.Find(pid) + if err != nil { + t.Fatal(err) + } + if pid != "grok-plugin" || !searchesItself(*p, provider.Responses) || searchesItself(*p, provider.Chat) { + t.Fatalf("%s searches by itself: %v on Responses, %v on Chat", pid, searchesItself(*p, provider.Responses), searchesItself(*p, provider.Chat)) + } +} diff --git a/internal/gateway/plugin_plan_lacks_test.go b/internal/gateway/plugin_plan_lacks_test.go new file mode 100644 index 00000000..a133549b --- /dev/null +++ b/internal/gateway/plugin_plan_lacks_test.go @@ -0,0 +1,89 @@ +package gateway + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/provider" +) + +// A plugin's account whose plan lacks the model is tried after the ones +// that list it, as a Codex account's is (a Free one behind a Plus): the +// plugin's first account, told only fake-1, isn't sent fake-resp. +func TestPluginAccountLackingTheModelGoesLast(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + fresh(t) + t.Setenv("MAGPIE_BUN", bun) + t.Setenv("FAKE_RESPONSES", "1") + t.Cleanup(plugin.Settle) + var mu sync.Mutex + var keys []string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + keys = append(keys, r.Header.Get("Authorization")) + mu.Unlock() + done := `{"id":"r1","object":"response","status":"completed","output":[{"type":"message","id":"m1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hi","annotations":[]}]}],"usage":{"input_tokens":5,"output_tokens":1}}` + if !strings.Contains(string(b), `"stream":true`) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, done) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse(`event: response.completed`+"\n"+`data: {"type":"response.completed","response":`+done+`}`)) + })) + defer up.Close() + t.Setenv("FAKE_BASE", up.URL+"/v1") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + // "few" is told only fake-1 + for i, key := range []string{"few", "full"} { + account := "" + if i > 0 { + account = plugin.NewAccount + } + if _, err := plugin.APIKey(ctx, "fakeco", 0, nil, key, account); err != nil { + t.Fatal(err) + } + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + p, err := provider.Find("fakeco") + if err != nil || len(p.AlsoOn()) != 1 { + t.Fatalf("Find(fakeco) = %+v, %v", p, err) + } + if p.Account.Lists("fake-resp") || !p.Account.Lists("fake-1") || !p.AlsoOn()[0].Account.Lists("fake-resp") { + t.Fatal("the accounts' lists aren't what the plugin told") + } + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", + strings.NewReader(`{"model":"fakeco/fake-resp","stream":false,"input":"hi"}`))) + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + mu.Lock() + defer mu.Unlock() + if len(keys) != 1 || keys[0] != "Bearer full" { + t.Fatalf("the vendor was asked with %v", keys) + } +} diff --git a/internal/gateway/search.go b/internal/gateway/search.go index 9e1409e7..159972ed 100644 --- a/internal/gateway/search.go +++ b/internal/gateway/search.go @@ -62,8 +62,9 @@ func searchFor(ctx context.Context) *CallFor { // asked on this API. func searchesItself(p provider.Provider, proto provider.Protocol) bool { if p.Account != nil { - // Grok by its id: moved to its plugin, its account is the plugin's - return (p.Account.Agent == "codex" || p.ID == "grok") && proto == provider.Responses + // Grok by its id: moved to its plugin, its account is the plugin's; + // its plugin beside the built-in (grok-plugin) by the plugin's id + return (p.Account.Agent == "codex" || p.ID == "grok" || p.PluginProvider() == "grok") && proto == provider.Responses } // a relay said to search (#359), on an API it has an address for: one // with only a Chat address would be sent no search tool at all diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index efa306cc..bc3cf967 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2508,6 +2508,8 @@ function askForgetSaved(x) { // accountPlan is the account's chip: its vendor and plan, the plan alone // when it already names the vendor (a plugin's own "Zed Pro"). function accountPlan(a) { + // a plugin beside a built-in (cursor-plugin) by the built-in's + const agent = a.builtin || a.agent; const cap = (s) => s[0].toUpperCase() + s.slice(1); // "Xiaomi MiMo" and "MiMo 高阶" overlap in "MiMo": said once const named = (name, plan) => { @@ -2518,20 +2520,20 @@ function accountPlan(a) { } return name + " " + plan; }; - if (a.agent === "codex") return named("ChatGPT", a.plan && cap(a.plan)); - if (a.agent === "copilot") return "GitHub"; - if (a.agent === "claude") return named("Claude", a.plan && cap(a.plan)); - if (a.agent === "cursor") return named("Cursor", a.plan && cap(a.plan)); - if (a.agent === "grok") return a.plan || "SuperGrok"; - if (a.agent === "gemini" || a.agent === "antigravity") return a.plan || "Google"; - if (a.agent === "zcode") return a.plan || "GLM Coding Plan"; - if (a.agent === "workbuddy") return a.plan || "WorkBuddy"; - if (a.agent === "workbuddy-ai") return a.plan || "WorkBuddy AI"; - if (a.agent === "commandcode-plan") return named("Command Code", a.plan && t(a.plan)); - if (a.agent === "qoder") return named("Qoder", a.plan && t(a.plan)); - if (a.agent === "qoder-cn") return named("Qoder CN", a.plan && t(a.plan)); - if (a.agent === "zed") return named("Zed", a.plan && t(a.plan)); - if (a.agent === "mimo-app") return named("Xiaomi MiMo", a.plan && t(a.plan)); + if (agent === "codex") return named("ChatGPT", a.plan && cap(a.plan)); + if (agent === "copilot") return "GitHub"; + if (agent === "claude") return named("Claude", a.plan && cap(a.plan)); + if (agent === "cursor") return named("Cursor", a.plan && cap(a.plan)); + if (agent === "grok") return a.plan || "SuperGrok"; + if (agent === "gemini" || agent === "antigravity") return a.plan || "Google"; + if (agent === "zcode") return a.plan || "GLM Coding Plan"; + if (agent === "workbuddy") return a.plan || "WorkBuddy"; + if (agent === "workbuddy-ai") return a.plan || "WorkBuddy AI"; + if (agent === "commandcode-plan") return named("Command Code", a.plan && t(a.plan)); + if (agent === "qoder") return named("Qoder", a.plan && t(a.plan)); + if (agent === "qoder-cn") return named("Qoder CN", a.plan && t(a.plan)); + if (agent === "zed") return named("Zed", a.plan && t(a.plan)); + if (agent === "mimo-app") return named("Xiaomi MiMo", a.plan && t(a.plan)); return a.plan || t("signed in"); } @@ -5929,7 +5931,7 @@ function renderAccounts(a) { dot.title = on ? t("Stop using this account") : t("Use this account too"); dot.onclick = () => accountAction("login/" + (on ? "off" : "on"), { agent: a.agent, user: l.user }); } - row.append(dot, el("span", "n", l.user), el("span", "plan", accountPlan({ agent: a.agent, plan: l.plan })), el("span", "grow")); + row.append(dot, el("span", "n", l.user), el("span", "plan", accountPlan({ agent: a.agent, builtin: a.builtin, plan: l.plan })), el("span", "grow")); if (l.active) { const using = el("span", "using", l.paused ? t("Paused") : back ? t("First for now") : several ? t("First") : t("Current")); if (back && !l.paused) using.title = t("{user} was nearly used up, so magpie signed {agent} in to this one; it goes back to {user} once that has room again", { user: back.user, agent: a.agentName }); @@ -7017,7 +7019,9 @@ function renderQuotas() { const use = el("button", "text", t("Use a reset")); use.title = resetUseTitle(sub); use.onclick = () => askReset(sub); - if (!sub.resets.byWindow) { // a GLM team's are spent on bigmodel.cn + // only Codex's are spent from here: a GLM team's are spent on + // bigmodel.cn, a plugin's wherever its vendor spends them + if (!sub.resets.byWindow && sub.provider === "codex") { const auto = autoResetButton(sub, "text auto-reset"); if (auto) r.append(auto); r.append(use); @@ -7424,7 +7428,7 @@ function panelQuotaCard(q) { const use = el("button", "pq-use", t("Use one…")); use.title = resetUseTitle(q); use.onclick = () => askReset(q); - if (!q.resets.byWindow) { + if (!q.resets.byWindow && q.provider === "codex") { // as on the Usage page const auto = autoResetButton(q, "pq-use pq-auto"); if (auto) r.append(auto); r.append(use); diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 3019f26e..bb1adebc 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -131,6 +131,9 @@ type accountJSON struct { Icon string `json:"agentIcon"` // Logins are the agent's accounts magpie remembers, to switch between Logins []provider.Login `json:"logins,omitempty"` + // Builtin is a plugin's provider id, for a plugin beside a built-in + // subscription (cursor-plugin's cursor): its plan named as the built-in's + Builtin string `json:"builtin,omitempty"` } type providerAgent struct { @@ -315,7 +318,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { if pp, ok := provider.PluginOf(p.ID); ok && p.IsPlugin() { // a plugin's sign-in: named for the provider it signs in to, // the page following it by the provider's id - out.Account.Agent, out.Account.Name, out.Account.Icon = p.ID, pp.Name, pluginIcon(pp) + out.Account.Agent, out.Account.Name, out.Account.Icon, out.Account.Builtin = p.ID, pp.Name, pluginIcon(pp), pp.ID if provider.Moved(pp.ID) { out.Account.Icon = p.Icon // the built-in's, as it was } diff --git a/internal/gui/tests/codex-auto-reset.test.cjs b/internal/gui/tests/codex-auto-reset.test.cjs index ae6d0e19..f6ba1ba2 100644 --- a/internal/gui/tests/codex-auto-reset.test.cjs +++ b/internal/gui/tests/codex-auto-reset.test.cjs @@ -4,8 +4,8 @@ // on the Usage page's card, and "Use one…" on the menu bar panel's, an // "Auto-use" toggle, off until turned on, posts settings/codex-auto-reset // for that account and shows itself pressed; turned off again the same way. -// A GLM team's resets, spent on its own site, and an account with no name -// get none. No click moves the page; no left-border accent. English and +// A GLM team's resets, spent on its own site, a plugin's, and an account +// with no name get none, nor a button to use one. No click moves the page; no left-border accent. English and // Chinese, Chromium and WebKit; no backend, the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); @@ -21,11 +21,14 @@ const quotas = [ resets: { count: 2, until: later } }, { provider: "zhipu", name: "GLM Coding", icon: "zhipu-color", user: "team@example.com", plan: "Team", windows: [{ name: "5 hours", used: 30 }], resets: { count: 1, byWindow: true, fiveHour: 1 } }, + // a plugin's, its resets told but not spent from magpie: never sent to Codex's + { provider: "codex-plugin", name: "Codex (plugin)", user: "Me@example.com", plan: "Plus", + windows: [{ name: "7 days", used: 100, resetsAt: later }], resets: { count: 3, until: later } }, ]; function serve(lang, posts) { let auto = []; - const settings = () => ({ theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", trayUsages: ["codex"], trayUsageEvery: 3, codexAutoReset: auto }); + const settings = () => ({ theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", trayUsages: ["codex", "zhipu", "codex-plugin"], trayUsageEvery: 3, codexAutoReset: auto }); return async (route) => { const req = route.request(), url = new URL(req.url()); const json = (data) => route.fulfill({ json: data }); @@ -101,6 +104,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const sel = ".quota-resets .auto-reset"; await page.locator(sel).waitFor(); assert.equal(await page.locator(sel).count(), 1, "the GLM team's resets have no toggle"); + assert.equal(await page.locator(".quota-resets").count(), 3, "every account's resets are told"); + assert.equal(await page.locator(".quota-resets button").count(), 2, "only Codex's resets are used from here"); assert.equal((await page.locator(sel).textContent()).trim(), w.auto); assert.equal(await page.locator(sel).getAttribute("aria-pressed"), "false", "off until turned on"); assert(await page.locator(sel).getAttribute("title")); @@ -119,6 +124,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await panel.locator(psel).waitFor(); assert.equal(await panel.locator(psel).getAttribute("aria-pressed"), "false"); assert.equal((await panel.locator(psel).textContent()).trim(), w.auto); + assert.equal(await panel.locator(".pq-resets").count(), 3); + assert.equal(await panel.locator(".pq-resets button").count(), 2, "only Codex's resets are used from the panel"); await flip(panel, psel, panelPosts, true, w.on); // still beside "Use one…", on one line const [a, u] = await panel.evaluate(() => [...document.querySelectorAll(".pq-resets button")].map((b) => b.getBoundingClientRect().top)); diff --git a/internal/gui/tests/plugin-plan.test.cjs b/internal/gui/tests/plugin-plan.test.cjs new file mode 100644 index 00000000..6158aec2 --- /dev/null +++ b/internal/gui/tests/plugin-plan.test.cjs @@ -0,0 +1,56 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A plugin's account beside a built-in subscription (cursor-plugin, the +// account's builtin "cursor") reads its plan as the built-in's does: +// "Cursor Pro", "SuperGrok" with no plan told, not "Pro" or "signed in". +// A plugin of its own keeps its plan as told. English and Chinese; no +// backend, 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"); + +function serve(lang) { + const settings = { theme: "light", lang, tray: "panel" }; + 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:false};` }); + 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 }); + if (url.pathname === "/api/settings") return json(settings); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + 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 of ["en", "zh"]) { + test(`${engine} ${lang}: a plugin's plan is named as its built-in's`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 700 } })).newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang)); + await page.goto("http://magpie.test/"); + await page.waitForFunction(() => typeof accountPlan === "function"); + const plans = await page.evaluate(() => [ + accountPlan({ agent: "cursor-plugin", builtin: "cursor", plan: "pro" }), + accountPlan({ agent: "cursor", plan: "pro" }), + accountPlan({ agent: "grok-plugin", builtin: "grok" }), + accountPlan({ agent: "grok" }), + accountPlan({ agent: "fakeco", builtin: "fakeco", plan: "Max" }), + ]); + assert.deepEqual(plans.slice(0, 4), ["Cursor Pro", "Cursor Pro", "SuperGrok", "SuperGrok"]); + assert.equal(plans[4], "Max"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index c9a587dc..eab657ba 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -24,6 +24,8 @@ export const FakePlugin = async ({ client }) => ({ }, } // $FAKE_RESPONSES: one model more, on OpenAI's Responses (Grok's) + // $FAKE_FAST: fake-1 has a fast one, as a Cursor model has its -fast + if (process.env.FAKE_FAST) cfg.provider[ID].models["fake-1-fast"] = { name: "Fake One Fast", limit: { context: 1000, output: 100 } } if (process.env.FAKE_RESPONSES) cfg.provider[ID].models["fake-resp"] = { name: "Fake Responses", provider: { npm: "@ai-sdk/openai" }, limit: { context: 4000, output: 400 } } }, auth: { diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go index 95e56b9b..2c5714f3 100644 --- a/internal/provider/codex_models.go +++ b/internal/provider/codex_models.go @@ -240,6 +240,9 @@ func (a *Account) Lists(model string) bool { if a == nil { return true } + if a.plugin != nil { + return a.pluginLists(model) + } live, _, ok := catalog.Live(accountModels(a.Agent, a.User)) if !ok { return true diff --git a/internal/provider/groupfast.go b/internal/provider/groupfast.go index 998ea7b0..6038284e 100644 --- a/internal/provider/groupfast.go +++ b/internal/provider/groupfast.go @@ -52,6 +52,16 @@ func (g Group) IsFast(id string) bool { // A Claude subscription runs Claude Code itself, and a relay or a cloud // (Bedrock, Vertex) may refuse the field, so neither is. func CanFast(p Provider, model string) bool { + if p.IsPlugin() { + // Cursor's plugin, as the built-in, asks for the model's -fast one + // when the chat request says service_tier "priority" + pp, ok := PluginOf(p.ID) + if !ok || p.PluginProvider() != "cursor" || strings.HasSuffix(model, "-fast") { + return false + } + _, ok = pluginModel(pp, model+"-fast") + return ok + } if p.Account != nil { switch p.Account.Agent { case "codex": diff --git a/internal/provider/importapps.go b/internal/provider/importapps.go index 1ee4c68d..3b4ae6ba 100644 --- a/internal/provider/importapps.go +++ b/internal/provider/importapps.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/tidwall/jsonc" + "github.com/yetone/magpie/internal/plugin" _ "modernc.org/sqlite" ) @@ -149,6 +150,9 @@ func freeID(id string) string { for _, id := range accountIDs { taken[id] = true } + for _, pp := range plugin.Cached() { // signed in or not, as a built-in's + taken[PluginID(pp.ID)] = true + } for _, p := range All() { taken[p.ID] = true } diff --git a/internal/provider/plugin_id_reserved_test.go b/internal/provider/plugin_id_reserved_test.go new file mode 100644 index 00000000..72ff51d1 --- /dev/null +++ b/internal/provider/plugin_id_reserved_test.go @@ -0,0 +1,62 @@ +package provider + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// An installed plugin's provider id is kept for it signed in or not, as a +// built-in subscription's is: a provider of the user's own saved on it, or +// renamed onto it, would hide the plugin's subscription once signed in. +func TestPluginIDReservedSignedOut(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + claudeHome(t) + t.Setenv("MAGPIE_BUN", bun) + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + if _, err := Find("fakeco"); err == nil { + t.Fatal("fakeco is listed before it is signed in") + } + // as the built-in codex, signed out, is kept + for _, id := range []string{"codex", "fakeco"} { + if err := Save(Provider{ID: id, Name: id, Chat: "http://127.0.0.1:1/v1", Key: "k", Models: []string{"m"}}); err == nil { + t.Fatalf("a provider of a key was saved on %s", id) + } + if got := freeID(id); got != id+"-2" { + t.Fatalf("freeID(%s) = %s", id, got) + } + } + if err := Save(Provider{ID: "mine", Name: "mine", Chat: "http://127.0.0.1:1/v1", Key: "k", Models: []string{"m"}}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"codex", "fakeco"} { + if err := Rename("mine", id); err == nil { + t.Fatalf("a provider was renamed onto %s", id) + } + } + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k-123456"); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + if p, err := Find("fakeco"); err != nil || !p.IsPlugin() { + t.Fatalf("Find(fakeco) = %+v, %v", p, err) + } +} diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index ef224960..575d36c3 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -45,6 +45,17 @@ func PluginID(id string) string { return id } +// subscriptionID is whether id is a subscription's, a built-in's or an +// installed plugin's, signed in or not: a provider of the user's own never +// takes it, or it would hide that subscription once signed in. +func subscriptionID(id string) bool { + if slices.Contains(accountIDs, id) { + return true + } + _, ok := PluginOf(id) + return ok +} + // IsPlugin is whether the provider is a plugin's. func (p Provider) IsPlugin() bool { return p.Account != nil && p.Account.plugin != nil } @@ -191,6 +202,21 @@ func pluginAccountCatalog(pp plugin.Provider, key string) []catalog.Model { return all } +// 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 { + pp := *a.plugin + if cur, ok := PluginOf(PluginID(pp.ID)); ok { + pp = cur + } + for _, ac := range pp.Accounts { + if ac.Key == a.pluginKey && ac.Models != nil { + return slices.Contains(ac.Models, model) + } + } + return true +} + // pluginProvider is the provider as one of its accounts, l as the // accounts list has it. func pluginProvider(pp plugin.Provider, l pluginLogin) Provider { diff --git a/internal/provider/pluginsignin_test.go b/internal/provider/pluginsignin_test.go index 514b7e13..16117b7a 100644 --- a/internal/provider/pluginsignin_test.go +++ b/internal/provider/pluginsignin_test.go @@ -4,6 +4,7 @@ import ( "context" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -72,15 +73,19 @@ func TestPluginSignIn(t *testing.T) { if p, err := Find("fakeco"); err != nil || !p.IsPlugin() { t.Fatalf("Find = %+v, %v", p, err) } + // removed, it is hidden as a built-in is, still signed in if err := Delete("fakeco"); err != nil { t.Fatal(err) } - if plugin.SignedIn("fakeco") { - t.Fatal("removing the account didn't sign out") + if !plugin.SignedIn("fakeco") { + t.Fatal("removing the provider signed out") } if _, err := Find("fakeco"); err == nil { t.Fatal("fakeco is a provider after it was removed") } + if h := Hidden(); len(h) != 1 || h[0].ID != "fakeco" { + t.Fatalf("Hidden = %+v", h) + } if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, " "); err == nil { t.Fatal("an empty key was taken") @@ -217,11 +222,18 @@ func TestPluginAccounts(t *testing.T) { t.Fatalf("keys listed as %v", us) } - // removing the provider signs every account out + // removing the provider hides it, as a built-in's, every account kept; + // shown again, they are all there if err := Delete("fakeco"); err != nil { t.Fatal(err) } - if plugin.SignedIn("fakeco") || len(Logins("fakeco")) != 0 { - t.Fatalf("still signed in: %v", users()) + if _, err := Find("fakeco"); err == nil || !plugin.SignedIn("fakeco") { + t.Fatalf("removed: %v, signed in %v", err, plugin.SignedIn("fakeco")) + } + if err := ShowAccount("fakeco"); err != nil { + t.Fatal(err) + } + if got := users(); !slices.Equal(got, us) { + t.Fatalf("shown again with %v, was %v", got, us) } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e48460a1..fd0e6619 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -8,7 +8,6 @@ package provider import ( - "context" "encoding/json" "errors" "fmt" @@ -18,10 +17,8 @@ import ( "regexp" "slices" "strings" - "time" "github.com/yetone/magpie/internal/catalog" - "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/settings" "github.com/yetone/magpie/internal/steady" ) @@ -390,7 +387,7 @@ func Save(p Provider) error { p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID)} } else { p.AccountProxies = nil // a provider of a key has no accounts to proxy apart - if slices.Contains(accountIDs, p.ID) && !stored(p.ID) { + if subscriptionID(p.ID) && !stored(p.ID) { // taken, it would hide that subscription once signed in return fmt.Errorf("%q is the id of the %s subscription; pick another name", p.ID, p.ID) } @@ -597,17 +594,10 @@ func ShowAccount(id string) error { return nil } -// Delete removes a provider. An account is only hidden from magpie (its -// model picks kept); signing out is the agent's job. +// Delete removes a provider. An account, a built-in's or a plugin's, is +// only hidden from magpie (its accounts and model picks kept, shown again +// from Hidden or by signing in); signing out is each account's Remove. func Delete(id string) error { - if p, ok := find(Accounts(), id); ok && p.IsPlugin() && !Moved(p.Account.plugin.ID) { - // a plugin's sign-in is magpie's own: removing it signs out. A - // built-in moved onto its plugin is only hidden, as the built-in - // was, its accounts and model picks kept. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - return plugin.SignOut(ctx, p.Account.plugin.ID, "") - } f, err := read() if err != nil { return err diff --git a/internal/provider/rename.go b/internal/provider/rename.go index cf1a260c..814df6dd 100644 --- a/internal/provider/rename.go +++ b/internal/provider/rename.go @@ -38,7 +38,7 @@ func Rename(from, to string) error { // subscription itself can't custom := i >= 0 && hasEndpoint(f.Providers[i]) _, signedIn := find(Accounts(), from) - sub := slices.Contains(accountIDs, from) + sub := subscriptionID(from) if (signedIn || sub) && !custom { return fmt.Errorf("%s is a subscription: its id is its agent's", from) } @@ -47,7 +47,7 @@ func Rename(from, to string) error { return errors.New(`"magpie" is what agents call the gateway itself; pick another id`) case to == strings.TrimSuffix(GroupPrefix, "/"): return errors.New(`"group" starts the ids of routing groups; pick another id`) - case slices.Contains(accountIDs, to): + case subscriptionID(to): return fmt.Errorf("%q is the id of the %s subscription; pick another", to, to) } if i < 0 {