plugins: a plugin's subscription does what a built-in's does, in seven places it didn't (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — an installed plugin's provider id is kept for it signed in or not, as a built-in's is: a provider of the user's own isn't saved on it, renamed onto it or imported as it (freeID gives id-2). A plugin's account whose plan lacks the model is tried after the ones that list it, as a Codex Free account behind a Plus, by the list the plugin told for it. Cursor's plugin has fast mode where the built-in Cursor has it: a model with a -fast one is saved :fast in a group and sent service_tier "priority", one without is refused. Removing a plugin's provider hides it as a built-in's is, its accounts kept and shown again from Hidden or by signing in, rather than signing every account out ("stays signed in" said so already). magpie accounts lists, adds, switches and forgets a plugin's accounts by its provider's id or name. Grok's plugin beside the built-in Grok (grok-plugin) searches by itself as Grok does. A plugin's plan is named as its built-in's ("Cursor Pro", "SuperGrok"), and a plugin's resets are shown but have no Use button or Auto-use, which spent Codex's reset of the same user. TestPluginIDReservedSignedOut, TestPluginAccountLackingTheModelGoesLast, TestCursorPluginGroupFast, TestAccountsOfAPlugin, TestGrokPluginSearchesItself, plugin-plan and codex-auto-reset fail without the change; TestPluginSignIn and TestPluginAccounts now expect a removed plugin hidden. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut failed once under load, passes alone); the gateway, provider and plugin suites and TestAccountsOfAPlugin pass on Windows, the plugin tests on Linux.
This commit is contained in:
19 files changed
+467
-48
No files matched your search
+8
-4
@@ -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 <claude|codex|gemini|antigravity|zed|factory|mimo> | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget <claude|codex|gemini|antigravity|zed|factory|mimo> <email> | magpie accounts project <gemini|antigravity> <email> <gcp-project-id>"
|
||||
const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|<plugin>] [--json] | magpie accounts add <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> <email> | magpie accounts project <gemini|antigravity> <email> <gcp-project-id>"
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+21
-17
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user