From b63a0f1fd5337005dab0bbc282133270eebcd314 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 19:58:20 +0800 Subject: [PATCH] =?UTF-8?q?plugins:=20a=20plugin's=20provider=20takes=20mo?= =?UTF-8?q?re=20than=20one=20account,=20as=20a=20built-in=20subscription?= =?UTF-8?q?=20does=20(the=20user:=20=E6=8F=92=E4=BB=B6=E5=BD=93=E7=84=B6?= =?UTF-8?q?=E8=A6=81=E6=94=AF=E6=8C=81=E5=A4=9A=E8=B4=A6=E5=8F=B7=E4=BA=86?= =?UTF-8?q?)=20=E2=80=94=20a=20plugin=20kept=20one=20sign-in=20per=20provi?= =?UTF-8?q?der,=20and=20signing=20in=20again=20replaced=20it.=20Now=20each?= =?UTF-8?q?=20sign-in=20goes=20beside=20the=20others:=20plugin-auth.json?= =?UTF-8?q?=20keeps=20the=20first=20under=20the=20provider's=20id,=20as=20?= =?UTF-8?q?OpenCode=20does=20(so=20what=20is=20signed=20in=20already=20sta?= =?UTF-8?q?ys=20the=20first=20account),=20the=20rest=20under=20id#slot;=20?= =?UTF-8?q?logins.json=20lists=20them=20as=20it=20lists=20the=20built-ins'?= =?UTF-8?q?=20accounts,=20so=20the=20editor=20shows=20them=20all,=20one=20?= =?UTF-8?q?first,=20the=20others=20ticked=20on=20behind=20it=20or=20off,?= =?UTF-8?q?=20any=20made=20first,=20any=20removed=20(which=20signs=20it=20?= =?UTF-8?q?out).=20The=20host=20runs=20every=20request=20in=20its=20accoun?= =?UTF-8?q?t's=20scope,=20so=20a=20plugin's=20getAuth,=20client.auth.set?= =?UTF-8?q?=20(a=20refreshed=20token),=20loader=20and=20provider.models=20?= =?UTF-8?q?see=20that=20account=20alone;=20a=20sign-in=20to=20an=20account?= =?UTF-8?q?=20already=20there=20(the=20same=20id,=20else=20the=20same=20se?= =?UTF-8?q?cret)=20replaces=20that=20one's=20rather=20than=20listing=20it?= =?UTF-8?q?=20twice.=20The=20gateway=20spreads=20and=20fails=20over=20acro?= =?UTF-8?q?ss=20the=20accounts=20on,=20as=20for=20the=20built-ins.=20Accou?= =?UTF-8?q?nts=20with=20no=20id=20are=20told=20apart=20by=20the=20end=20of?= =?UTF-8?q?=20their=20key=20("API=20key=20=E2=80=A6abcd"),=20else=20by=20t?= =?UTF-8?q?heir=20slot.=20Also=20on=20the=20Devin=20page:=20the=20header?= =?UTF-8?q?=20shows=20the=20plugin's=20icon,=20and=20Cognition's=20opencod?= =?UTF-8?q?e-devin=20is=20off=20the=20market=20(its=20gateway=20answers=20?= =?UTF-8?q?404=20model=5Fnot=5Ffound=20for=20models=20and=20chat).=20TestP?= =?UTF-8?q?luginAccounts=20(plugin:=20per-account=20requests=20and=20refre?= =?UTF-8?q?shes,=20dedupe,=20sign-out;=20provider:=20first,=20on/off,=20sw?= =?UTF-8?q?itch,=20forget,=20labels),=20TestPluginProvider=20(gateway:=20a?= =?UTF-8?q?=20429=20on=20the=20first=20account=20goes=20on=20the=20second)?= =?UTF-8?q?=20and=20plugin-signin.test.cjs=20(add=20a=20second=20account?= =?UTF-8?q?=20in=20the=20editor,=20make=20it=20first;=20fails=20without=20?= =?UTF-8?q?the=20change)=20are=20new.=20go=20vet,=20go=20build=20-tags=20n?= =?UTF-8?q?ogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,?= =?UTF-8?q?=20go=20test=20-tags=20nogui=20./...=20pass;=20plugin-signin,?= =?UTF-8?q?=20plugin-market,=20old-webkit,=20click-scroll=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/plugin_test.go | 30 +++- internal/gui/assets/app.js | 4 +- internal/gui/assets/i18n.js | 1 + internal/gui/providers.go | 5 +- internal/gui/tests/plugin-signin.test.cjs | 46 +++-- internal/plugin/host.go | 1 + internal/plugin/host.js | 209 +++++++++++++++++----- internal/plugin/market.json | 2 - internal/plugin/plugin_test.go | 106 ++++++++++- internal/plugin/providers.go | 154 ++++++++++++---- internal/plugin/testdata/fake/index.js | 4 +- internal/provider/account.go | 1 + internal/provider/logins.go | 9 + internal/provider/logins_on.go | 6 + internal/provider/plugin_accounts.go | 155 ++++++++++++++++ internal/provider/plugins.go | 27 ++- internal/provider/pluginsignin.go | 22 ++- internal/provider/pluginsignin_test.go | 135 +++++++++++++- internal/provider/provider.go | 2 +- plugins_cli.go | 10 +- 20 files changed, 785 insertions(+), 144 deletions(-) create mode 100644 internal/provider/plugin_accounts.go diff --git a/internal/gateway/plugin_test.go b/internal/gateway/plugin_test.go index cabf7709..29c0c6f7 100644 --- a/internal/gateway/plugin_test.go +++ b/internal/gateway/plugin_test.go @@ -35,11 +35,19 @@ func TestPluginProvider(t *testing.T) { } var mu sync.Mutex var got []seen + limited := "" // the Authorization answered 429 up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) mu.Lock() got = append(got, seen{r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Plugin-Model"), string(b)}) + out := limited != "" && r.Header.Get("Authorization") == limited mu.Unlock() + if out { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(429) + fmt.Fprint(w, `{"error":{"message":"rate limited"}}`) + return + } w.Header().Set("Content-Type", "text/event-stream") switch { case r.URL.Path == "/v1/chat/completions": @@ -77,7 +85,7 @@ func TestPluginProvider(t *testing.T) { if _, err := provider.Find("fakeco"); err == nil { t.Fatal("fakeco is a provider before it is signed in to") } - if _, err := plugin.APIKey(ctx, "fakeco", 0, nil, "k1"); err != nil { + if _, err := plugin.APIKey(ctx, "fakeco", 0, nil, "k1", plugin.NewAccount); err != nil { t.Fatal(err) } p, err := provider.Find("fakeco") @@ -112,7 +120,25 @@ func TestPluginProvider(t *testing.T) { } } - if err := plugin.SignOut(ctx, "fakeco"); err != nil { + // a second account: the first rate limited, a request goes on the second + if _, err := provider.PluginAPIKey(ctx, "fakeco", 0, nil, "k2"); err != nil { + t.Fatal(err) + } + if ls := provider.Logins("fakeco"); len(ls) != 2 || !ls[1].On { + t.Fatalf("accounts %+v", ls) + } + mu.Lock() + limited = "Bearer k1" + mu.Unlock() + code, body := postAs(t, s, "", `{"model":"fakeco/fake-1","messages":[{"role":"user","content":"hello"}]}`) + mu.Lock() + last := got[len(got)-1] + mu.Unlock() + if code != 200 || !strings.Contains(body, "hi from chat") || last.auth != "Bearer k2" { + t.Fatalf("with the first account limited: %d %s, upstream saw %+v", code, body, last) + } + + if err := plugin.SignOut(ctx, "fakeco", ""); err != nil { t.Fatal(err) } if _, err := provider.Find("fakeco"); err == nil { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 81d00999..e8ba25d3 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3654,7 +3654,7 @@ function drawEditor(p, presetID) { // the sign-in belongs to the agent; magpie only borrows it const a = p.account; if (subOf(a.agent)) { - ed.append(...field(t("Accounts"), renderAccounts(a), p.routing ? t("Tick every account to use; Routing says how requests spread over them.") : subOf(a.agent).single ? t("{agent} keeps one account; the gateway runs it for every request. Signing in to another replaces it.", { agent: a.agentName }) : subOf(a.agent).own ? t("The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota. {agent} itself stays signed in as it is.", { agent: a.agentName }) : t("{agent} signs in to the first. Tick more and the gateway moves on to the next when the one before it is out of quota. Sessions already running keep theirs until restarted.", { agent: a.agentName }))); + ed.append(...field(t("Accounts"), renderAccounts(a), p.routing ? t("Tick every account to use; Routing says how requests spread over them.") : subOf(a.agent).single ? t("{agent} keeps one account; the gateway runs it for every request. Signing in to another replaces it.", { agent: a.agentName }) : subOf(a.agent).plugin ? t("The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota.") : subOf(a.agent).own ? t("The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota. {agent} itself stays signed in as it is.", { agent: a.agentName }) : t("{agent} signs in to the first. Tick more and the gateway moves on to the next when the one before it is out of quota. Sessions already running keep theirs until restarted.", { agent: a.agentName }))); if ((a.logins || []).filter((l) => l.active || l.on).length > 1) ed.append(...renderRouting(p)); } else { const acct = el("div", "acct"); @@ -4797,7 +4797,7 @@ const subOf = (agent) => SUBS.find((x) => x.agent === agent) || pluginSubs().fin // magpie, signs in and carries the requests. function pluginSubs() { return (providers.plugins || []).map((x) => ({ - agent: x.id, pid: x.pid, name: x.name, icon: x.icon, plugin: x, own: true, single: true, + agent: x.id, pid: x.pid, name: x.name, icon: x.icon, plugin: x, own: true, get plans() { return t("from the plugin {spec}", { spec: x.spec }); }, })); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index d6664e5f..01febf39 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -339,6 +339,7 @@ const I18N = { "When {name} is out of quota, rate limited or down, a request goes to these instead, top first. It only happens before any of the reply is sent, and {name} then sits out a minute.": "当 {name} 额度用完、被限流或不可用时,请求会按从上到下的顺序改发给这些模型。只在回复开始发送之前切换,之后 {name} 会暂停一分钟再用。", "{agent} and the gateway use the one in use. Sessions already running keep theirs until restarted.": "{agent} 和网关使用标记为「使用中」的账号。已在运行的会话保持原账号,重启后切换。", "The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota. {agent} itself stays signed in as it is.": "网关优先用第一个账号。多勾选几个,前一个额度用完时会自动换到下一个。{agent} 本身的登录保持不变。", + "The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota.": "网关优先用第一个账号。多勾选几个,前一个额度用完时会自动换到下一个。", "The gateway uses this account first": "网关优先使用这个账号", "Routing": "路由", "Tick every key to use; Routing says how requests spread over them.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index e14e8015..cd168f6b 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -275,7 +275,10 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { // 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.Spec, pp.ID) - out.Account.Logins = nil + out.Account.Logins = provider.Logins(p.ID) + if out.Icon == "" || out.Icon == "generic" { + out.Icon = out.Account.Icon + } } } exposed := map[string]bool{} diff --git a/internal/gui/tests/plugin-signin.test.cjs b/internal/gui/tests/plugin-signin.test.cjs index 58608f04..9349743b 100644 --- a/internal/gui/tests/plugin-signin.test.cjs +++ b/internal/gui/tests/plugin-signin.test.cjs @@ -3,8 +3,9 @@ // "From plugins"; its sign-in asks the way to sign in, the method's // questions (a pick, then a text the plugin checks), then opens the // browser and takes the code its page shows; an "api" way takes a key and -// opens the account. (The Plugins tab is plugin-market.test.cjs.) English -// and Chinese; the API is faked here. +// opens the account; another account is added beside it, as a built-in +// subscription's is, and can be put first. (The Plugins tab is +// plugin-market.test.cjs.) 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"); @@ -18,7 +19,12 @@ const plugin = { id: "fakeco", pid: "fakeco", name: "FakeCo", icon: "generic", s { type: "api", label: "Paste an existing FakeCo session token from another device you are signed in on" }] }; function server(lang, asked) { - let signedIn = false; + const accts = []; // who is signed in, the first in use first + const payload = () => { + const providers = [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }]; + if (accts.length) providers.push({ id: "fakeco", name: "FakeCo", icon: "generic", models: [], agents: [], key: {}, account: { agent: "fakeco", agentName: "FakeCo", agentIcon: "generic", user: accts[0], logins: accts.map((user, i) => ({ user, active: i === 0, on: true })) } }); + return { providers, presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [{ ...plugin, signedIn: accts.length > 0 }] }; + }; return async (route) => { const url = new URL(route.request().url()); const json = (data) => route.fulfill({ json: data }); @@ -26,10 +32,12 @@ function server(lang, asked) { 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: { lang, theme: "light" } }); - if (url.pathname === "/api/providers") { - const providers = [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }]; - if (signedIn) providers.push({ id: "fakeco", name: "FakeCo", icon: "", models: [], agents: [], key: {}, account: { agent: "fakeco", agentName: "FakeCo", agentIcon: "generic", user: "API key" } }); - return json({ providers, presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [{ ...plugin, signedIn }] }); + if (url.pathname === "/api/providers") return json(payload()); + if (url.pathname === "/api/login/switch") { + const b = body(); + asked.push(["switch", b]); + accts.unshift(...accts.splice(accts.indexOf(b.user), 1)); + return json(payload()); } if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); if (url.pathname === "/api/gateway/trace") return json({ routes: [] }); @@ -46,7 +54,7 @@ function server(lang, asked) { if (url.pathname === "/api/plugin-signin") { const b = body(); asked.push(["signin", b]); - if (b.key) { signedIn = true; return json({ agent: "fakeco", state: "done", user: "FakeCo" }); } + if (b.key) { accts.push("API key …" + b.key); return json({ agent: "fakeco", state: "done", user: accts.at(-1) }); } return json({ id: "p1", agent: "fakeco", state: "waiting", url: "https://fake.test/auth", pasteCode: true, instructions: "Paste the code FakeCo shows." }); } if (url.pathname === "/api/signin/p1/callback") { asked.push(["code", body()]); return route.fulfill({ status: 204 }); } @@ -59,8 +67,8 @@ function server(lang, asked) { } const L = { - en: { section: "From plugins", how: "How do you sign in to FakeCo?", next: "Next", code: "Code", finish: "Finish sign-in", key: "FakeCo API key", signIn: "Sign in" }, - zh: { section: "来自插件", how: "用哪种方式登录 FakeCo?", next: "下一步", code: "验证码", finish: "完成登录", key: "FakeCo API Key", signIn: "登录" }, + en: { section: "From plugins", how: "How do you sign in to FakeCo?", next: "Next", code: "Code", finish: "Finish sign-in", key: "FakeCo API key", signIn: "Sign in", add: "Add another FakeCo account", first: "Make first", note: "The gateway uses the first. Tick more" }, + zh: { section: "来自插件", how: "用哪种方式登录 FakeCo?", next: "下一步", code: "验证码", finish: "完成登录", key: "FakeCo API Key", signIn: "登录", add: "添加另一个 FakeCo 账号", first: "设为首选", note: "网关优先用第一个账号。多勾选几个" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -129,6 +137,24 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.waitForFunction(() => editing === "fakeco"); assert.deepEqual(asked.filter(([k]) => k === "signin").pop()[1], { provider: "fakeco", method: 0, inputs: {}, key: "k1" }); + // another account, beside the first: the editor adds it, lists + // both, and puts the second first when asked + const ed = page.locator("#modal .editor"); + const add = ed.locator(".accts .acc.add", { hasText: w.add }); + await add.waitFor(); + assert.match(await ed.innerText(), new RegExp(w.note)); + await add.click(); + await ed.locator(".signing button", { hasText: "API key" }).first().click(); + await ed.getByLabel(w.key).fill("k2"); + await ed.locator(".signing button", { hasText: w.signIn }).click(); + const rows = ed.locator(".accts .acc:not(.add)"); + await page.waitForFunction(() => document.querySelectorAll("#modal .editor .accts .acc:not(.add)").length === 2); + assert.deepEqual(await rows.locator(".n").allInnerTexts(), ["API key …k1", "API key …k2"]); + if (process.env.ARTIFACT_DIR) await ed.locator(".accts").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-accounts-${engine}-${lang}.png`) }); + await rows.nth(1).locator("button", { hasText: w.first }).click(); + await page.waitForFunction(() => document.querySelector("#modal .editor .accts .acc .n")?.textContent === "API key …k2"); + assert.deepEqual(asked.filter(([k]) => k === "switch").pop()[1], { agent: "fakeco", user: "API key …k2" }); + assert.deepEqual(errors, []); }); } diff --git a/internal/plugin/host.go b/internal/plugin/host.go index 20a50be4..5da127ea 100644 --- a/internal/plugin/host.go +++ b/internal/plugin/host.go @@ -400,6 +400,7 @@ func Plugins(ctx context.Context) ([]Loaded, error) { // FetchRequest is a request a provider's plugin makes. type FetchRequest struct { Provider string `json:"provider"` + Account string `json:"account,omitempty"` // the provider's first when "" Model string `json:"model"` NPM string `json:"npm"` URL string `json:"url"` diff --git a/internal/plugin/host.js b/internal/plugin/host.js index 98543ea0..f2edaa7f 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -14,7 +14,13 @@ // tui.showToast and app.log, config.get; Bun's $; the folder magpie keeps // its files in. What else a plugin asks the client for answers {data: // undefined}. +// +// A provider may be signed in to more than once: the first account is kept +// under the provider's id, as OpenCode keeps it, the others under +// id#. Everything a request does runs in its account's scope, so the +// plugin's getAuth, client.auth.set and loader see that account alone. +import { AsyncLocalStorage } from "node:async_hooks" import fs from "node:fs" import path from "node:path" import readline from "node:readline" @@ -35,7 +41,7 @@ let directory = process.cwd() let userConfig = {} const hooks = [] // {spec, hooks} const loaded = [] // {spec, id, error} -const loaders = new Map() // provider → options the auth loader returned +const loaders = new Map() // account → options the auth loader returned const sessions = new Map() // oauth sign-in in progress → its authorize result const inflight = new Map() // fetch id → AbortController let config = { provider: {} } // what the plugins' config hooks made of it @@ -58,20 +64,102 @@ function writeAuth(all) { fs.renameSync(tmp, authPath) } -function setAuth(id, info) { +function setAuth(key, info) { const all = readAuth() - all[id] = info + all[key] = info writeAuth(all) - loaders.delete(id) - send({ event: "auth", provider: id }) + loaders.delete(key) + send({ event: "auth", provider: providerOf(key), account: key }) } -function removeAuth(id) { +function removeAuth(key) { const all = readAuth() - delete all[id] + delete all[key] writeAuth(all) - loaders.delete(id) - send({ event: "auth", provider: id }) + loaders.delete(key) + send({ event: "auth", provider: providerOf(key), account: key }) +} + +// ---- accounts ---------------------------------------------------------------- + +// scope is the account a request is for: {provider, key}. +const scope = new AsyncLocalStorage() + +const providerOf = (key) => key.split("#")[0] + +// accountsOf are the keys provider's accounts are kept under, the one under +// its own id first. +function accountsOf(all, provider) { + return Object.keys(all) + .filter((k) => k === provider || k.startsWith(provider + "#")) + .sort((a, b) => (a === provider ? -1 : b === provider ? 1 : a < b ? -1 : a > b ? 1 : 0)) +} + +// keyFor is where the plugin's id is kept in this scope: the scope's +// account when the plugin names the scope's provider, else its own id. +function keyFor(id) { + const s = scope.getStore() + return s && s.provider === id ? s.key : id +} + +// accountKey is the account a request names, the provider's first when it +// names none. +function accountKey(provider, account) { + if (account && providerOf(account) === provider) return account + return accountsOf(readAuth(), provider)[0] ?? provider +} + +// freshKey is where a new sign-in to provider goes: its own id while that +// is free. +function freshKey(provider) { + const all = readAuth() + if (!(provider in all)) return provider + for (;;) { + const k = provider + "#" + Math.random().toString(36).slice(2, 8) + if (!(k in all)) return k + } +} + +function inScope(provider, key, fn) { + return scope.run({ provider, key }, fn) +} + +function whoOf(a) { + return a?.accountId ?? a?.metadata?.email ?? a?.email ?? "" +} + +// hintOf tells an account with no id from another: the end of its key. +function hintOf(a) { + return a?.type === "api" && typeof a.key === "string" && a.key.length >= 12 ? a.key.slice(-4) : "" +} + +function secretOf(a) { + return a?.type === "oauth" ? a.refresh ?? a.access ?? "" : a?.key ?? "" +} + +// settle keeps a sign-in just saved at key once: one to an account already +// signed in (the same account id, else the same secret) replaces that one's +// and goes. It gives where it is kept. +function settle(provider, key) { + const all = readAuth() + const now = all[key] + if (!now) return key + const who = whoOf(now) + const secret = secretOf(now) + for (const k of accountsOf(all, provider)) { + if (k === key) continue + const was = all[k] + if ((who && whoOf(was) === who) || (!who && !whoOf(was) && secret && secretOf(was) === secret)) { + all[k] = now + delete all[key] + writeAuth(all) + loaders.delete(k) + loaders.delete(key) + send({ event: "auth", provider, account: k }) + return k + } + } + return key } // ---- the client plugins are given -------------------------------------------- @@ -97,15 +185,17 @@ function makeClient() { const body = opts?.body ?? opts?.auth if (typeof id === "string" && body && typeof body === "object") { // OpenCode keeps what the plugin gives it, saving refreshed - // tokens over the old; nothing is merged - const prev = readAuth()[id] - if (JSON.stringify(prev) !== JSON.stringify(body)) setAuth(id, body) + // tokens over the old; nothing is merged. It goes to the + // account the request is for. + const key = keyFor(id) + const prev = readAuth()[key] + if (JSON.stringify(prev) !== JSON.stringify(body)) setAuth(key, body) } return { data: true } }, remove: async (opts) => { const id = opts?.path?.id ?? opts?.id - if (typeof id === "string") removeAuth(id) + if (typeof id === "string") removeAuth(keyFor(id)) return { data: true } }, }, @@ -301,7 +391,7 @@ function fromModelsDev(p, m) { // info is the provider as OpenCode builds it: models.dev's entry, what // the config (with the plugins' config hooks) says of it, the plugin's // provider.models hook. -async function info(id) { +async function info(id, key) { const md = mdev()[id] const cfg = config.provider?.[id] const out = { @@ -342,7 +432,8 @@ async function info(id) { const ph = h.hooks.provider if (ph?.id !== id || typeof ph.models !== "function") continue try { - const next = await ph.models(JSON.parse(JSON.stringify(out)), { auth: readAuth()[id] }) + const all = readAuth() + const next = await ph.models(JSON.parse(JSON.stringify(out)), { auth: all[key ?? accountsOf(all, id)[0] ?? id] }) out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }])) } catch (e) { send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` }) @@ -360,7 +451,9 @@ async function providers() { const stored = readAuth() const out = [] for (const [id, a] of auths()) { + const keys = accountsOf(stored, id) const p = await info(id) + const first = stored[keys[0]] out.push({ id, spec: a.spec, @@ -368,9 +461,10 @@ async function providers() { npm: p.npm ?? "", api: p.api ?? "", methods: methods(a.auth), - signedIn: !!stored[id], - authType: stored[id]?.type ?? "", - accountId: stored[id]?.accountId ?? stored[id]?.metadata?.email ?? stored[id]?.email ?? "", + signedIn: keys.length > 0, + authType: first?.type ?? "", + accountId: whoOf(first), + accounts: keys.map((k) => ({ key: k, type: stored[k]?.type ?? "", accountId: whoOf(stored[k]), hint: hintOf(stored[k]) })), models: Object.values(p.models) .filter((m) => m.status !== "deprecated") .map((m) => ({ @@ -431,28 +525,37 @@ function validate(provider, index, key, value) { return p.validate(value) ?? null } -// save keeps a successful sign-in as OpenCode's CLI does. -function save(provider, result, inputs, apiKey) { +// save keeps a successful sign-in as OpenCode's CLI does, at key (or, for +// a sign-in the plugin says is another provider's, a new account of +// that one). It gives the provider and where the account is kept. +function save(provider, key, result, inputs, apiKey) { const id = result.provider ?? provider + if (id !== provider) key = freshKey(id) if ("refresh" in result) { const { type: _t, provider: _p, refresh, access, expires, ...extra } = result - setAuth(id, { type: "oauth", refresh, access, expires, ...extra }) + setAuth(key, { type: "oauth", refresh, access, expires, ...extra }) } else if ("key" in result || apiKey) { const md = { ...(inputs && Object.keys(inputs).length ? inputs : {}), ...(result.metadata ?? {}) } - setAuth(id, { type: "api", key: result.key ?? apiKey, ...(Object.keys(md).length ? { metadata: md } : {}) }) + setAuth(key, { type: "api", key: result.key ?? apiKey, ...(Object.keys(md).length ? { metadata: md } : {}) }) } - return id + return { provider: id, account: settle(id, key) } +} + +// signInKey is where a sign-in goes: the account named, else a new one. +function signInKey(provider, account) { + return account && account !== "new" && providerOf(account) === provider ? account : freshKey(provider) } let nextSession = 1 -async function authorize({ provider, method, inputs }) { +async function authorize({ provider, method, inputs, account }) { const m = authOf(provider).methods[method] if (!m) throw new Error(`no sign-in method ${method} for ${provider}`) if (m.type !== "oauth") throw new Error("not an oauth method") - const a = await m.authorize(inputs ?? {}) + const key = signInKey(provider, account) + const a = await inScope(provider, key, () => m.authorize(inputs ?? {})) const session = String(nextSession++) - sessions.set(session, { provider, a, inputs }) + sessions.set(session, { provider, key, a, inputs }) return { session, url: a.url ?? "", instructions: a.instructions ?? "", method: a.method } } @@ -460,44 +563,47 @@ async function callback({ session, code }) { const s = sessions.get(session) if (!s) throw new Error("no such sign-in") sessions.delete(session) - const r = s.a.method === "code" ? await s.a.callback(code ?? "") : await s.a.callback() + const r = await inScope(s.provider, s.key, () => (s.a.method === "code" ? s.a.callback(code ?? "") : s.a.callback())) if (!r || r.type !== "success") return { ok: false } - return { ok: true, provider: save(s.provider, r, undefined) } + return { ok: true, ...save(s.provider, s.key, r, undefined) } } -async function apiKey({ provider, method, inputs, key }) { +async function apiKey({ provider, method, inputs, key, account }) { const m = authOf(provider).methods[method] if (!m || m.type !== "api") throw new Error("not an API key method") + const at = signInKey(provider, account) if (typeof m.authorize !== "function") { const md = inputs && Object.keys(inputs).length ? { metadata: inputs } : {} - setAuth(provider, { type: "api", key, ...md }) - return { ok: true, provider } + setAuth(at, { type: "api", key, ...md }) + return { ok: true, provider, account: settle(provider, at) } } - const r = await m.authorize(inputs ?? {}) + const r = await inScope(provider, at, () => m.authorize(inputs ?? {})) if (!r || r.type !== "success") return { ok: false } - return { ok: true, provider: save(provider, r, inputs, key) } + return { ok: true, ...save(provider, at, r, inputs, key) } } // ---- requests ---------------------------------------------------------------- -// options is what the provider's auth loader returned, run once per -// sign-in as OpenCode runs it once per start. -async function options(provider) { - if (loaders.has(provider)) return loaders.get(provider) +// options is what the provider's auth loader returned for the account at +// key, run once per sign-in as OpenCode runs it once per start. Run in +// the account's scope, whatever the loader keeps (its fetch) saves to it. +async function options(provider, key) { + if (loaders.has(key)) return loaders.get(key) const a = auths().get(provider)?.auth - const stored = readAuth()[provider] + const stored = readAuth()[key] let opts = {} if (a?.loader && stored) { - const p = await info(provider) - opts = (await a.loader(async () => readAuth()[provider], JSON.parse(JSON.stringify(p)))) ?? {} + const p = await info(provider, key) + opts = (await inScope(provider, key, () => a.loader(async () => readAuth()[key], JSON.parse(JSON.stringify(p))))) ?? {} } if (!opts.apiKey && stored?.type === "api") opts = { ...opts, apiKey: stored.key } - loaders.set(provider, opts) + loaders.set(key, opts) return opts } -async function load({ provider }) { - const o = await options(provider) +async function load({ provider, account }) { + const key = accountKey(provider, account) + const o = await inScope(provider, key, () => options(provider, key)) return { baseURL: typeof o.baseURL === "string" ? o.baseURL : "", apiKey: typeof o.apiKey === "string" ? o.apiKey : "", @@ -514,17 +620,22 @@ function sdkHeaders(npm, key) { return { authorization: `Bearer ${key}` } } -async function doFetch(id, { provider, model, npm, url, method, headers, body, session }) { +async function doFetch(id, params) { + const key = accountKey(params.provider, params.account) + return inScope(params.provider, key, () => fetchAs(id, key, params)) +} + +async function fetchAs(id, key, { provider, model, npm, url, method, headers, body, session }) { const ctl = new AbortController() inflight.set(id, ctl) try { - const o = await options(provider) + const o = await options(provider, key) const h = new Headers() for (const [k, v] of Object.entries(sdkHeaders(npm, o.apiKey))) h.set(k, v) for (const [k, v] of Object.entries(o.headers ?? {})) h.set(k, String(v)) for (const [k, v] of Object.entries(headers ?? {})) h.set(k, v) // chat.headers: what the plugins add to the request, theirs winning - const p = await info(provider) + const p = await info(provider, key) const m = p.models[model] ?? { id: model, providerID: provider, api: { id: model, npm } } for (const x of hooks) { const fn = x.hooks["chat.headers"] @@ -587,12 +698,14 @@ const handlers = { callback, apiKey, load, + // signOut forgets the account named, else every account of the provider signOut(p) { - removeAuth(p.provider) + const keys = p.account ? [p.account] : accountsOf(readAuth(), p.provider) + for (const k of keys) removeAuth(k) return null }, reload(p) { - loaders.delete(p.provider) + for (const k of p.account ? [p.account] : accountsOf(readAuth(), p.provider)) loaders.delete(k) return null }, } diff --git a/internal/plugin/market.json b/internal/plugin/market.json index 427828b2..05b01269 100644 --- a/internal/plugin/market.json +++ b/internal/plugin/market.json @@ -38,8 +38,6 @@ "summary": { "en": "Claude Pro and Max with your Claude account.", "zh": "用 Claude 账号使用 Claude Pro / Max。" } }, { "package": "@servoy/opencode-kiro-auth", "name": "Kiro", "icon": "kiro-color", "providers": ["kiro"], "summary": { "en": "Claude models on your AWS Kiro plan.", "zh": "用 AWS Kiro 套餐使用 Claude 模型。" } }, - { "package": "@cognitionai/opencode-devin", "name": "Devin by Cognition", "icon": "devin", "providers": ["devin"], - "summary": { "en": "Cognition's own plugin: sign in with Devin and use Devin's AI gateway.", "zh": "Cognition 官方插件:登录 Devin,使用 Devin 的 AI 网关。" } }, { "package": "opencode-qoder-bridge", "name": "Qoder (Agent SDK)", "icon": "qoder", "providers": ["qoder"], "summary": { "en": "Qoder through the official Qoder Agent SDK.", "zh": "通过官方 Qoder Agent SDK 使用 Qoder。" } } ] diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index c561e211..9243870e 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -87,21 +87,21 @@ func TestFakePlugin(t *testing.T) { t.Fatalf("Validate = %q", e) } in := map[string]string{"where": "work", "team": "blue"} - a, err := Authorize(ctx, "fakeco", 1, in) + a, err := Authorize(ctx, "fakeco", 1, in, NewAccount) if err != nil || a.Method != "code" || !strings.Contains(a.URL, "where=work") { t.Fatalf("Authorize = %+v, %v", a, err) } if _, err := Finish(ctx, a.Session, "bad"); err != ErrFailed { t.Fatalf("a bad code: %v", err) } - a, _ = Authorize(ctx, "fakeco", 1, in) - if got, err := Finish(ctx, a.Session, "good"); err != nil || got != "fakeco" { - t.Fatalf("Finish = %q, %v", got, err) + a, _ = Authorize(ctx, "fakeco", 1, in, NewAccount) + if got, err := Finish(ctx, a.Session, "good"); err != nil || got != (Saved{"fakeco", "fakeco"}) { + t.Fatalf("Finish = %+v, %v", got, err) } var saved map[string]map[string]any b, _ := os.ReadFile(AuthPath()) json.Unmarshal(b, &saved) - if saved["fakeco"]["type"] != "oauth" || saved["fakeco"]["refresh"] != "r-blue" || saved["fakeco"]["accountId"] != "me@fake" { + if saved["fakeco"]["type"] != "oauth" || saved["fakeco"]["refresh"] != "r-blue" || saved["fakeco"]["accountId"] != "blue@fake" { t.Fatalf("saved %v", saved) } if fi, _ := os.Stat(AuthPath()); fi.Mode().Perm() != 0o600 { @@ -111,7 +111,7 @@ func TestFakePlugin(t *testing.T) { t.Fatal("not signed in") } - o, err := LoaderOptions(ctx, "fakeco") + o, err := LoaderOptions(ctx, "fakeco", "") if err != nil || o.BaseURL != srv.URL+"/v1" || !o.Fetch || o.APIKey != "dummy" { t.Fatalf("LoaderOptions = %+v, %v", o, err) } @@ -135,12 +135,12 @@ func TestFakePlugin(t *testing.T) { h := seen[len(seen)-1] // the plugin refreshed the stale token and saved it; the chat.headers // hook added its header; the plugin saw the provider's models - if h.Get("Authorization") != "Bearer fresh" || h.Get("X-Plugin-Model") != "fake-1" || h.Get("X-Models") != "fake-1,fake-claude,fake-gemini" { + if h.Get("Authorization") != "Bearer fresh-r-blue" || h.Get("X-Plugin-Model") != "fake-1" || h.Get("X-Models") != "fake-1,fake-claude,fake-gemini" { t.Fatalf("headers = %v", h) } b, _ = os.ReadFile(AuthPath()) json.Unmarshal(b, &saved) - if saved["fakeco"]["access"] != "fresh" { + if saved["fakeco"]["access"] != "fresh-r-blue" { t.Fatalf("refresh not saved: %v", saved) } @@ -156,7 +156,7 @@ func TestFakePlugin(t *testing.T) { t.Fatal(err) } - if err := SignOut(ctx, "fakeco"); err != nil || SignedIn("fakeco") { + if err := SignOut(ctx, "fakeco", ""); err != nil || SignedIn("fakeco") { t.Fatalf("SignOut: %v", err) } if err := SetOff(abs, true); err != nil { @@ -166,3 +166,91 @@ func TestFakePlugin(t *testing.T) { t.Fatalf("a plugin turned off still lists %+v", ps) } } + +// A provider is signed in to more than once: each account is kept apart, +// its requests made with its own sign-in and its refreshes saved to it; a +// sign-in to an account already there replaces it. +func TestPluginAccounts(t *testing.T) { + sandbox(t) + var auths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auths = append(auths, r.Header.Get("Authorization")) + fmt.Fprint(w, "{}") + })) + defer srv.Close() + t.Setenv("FAKE_BASE", srv.URL+"/v1") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("testdata/fake/index.js") + if _, err := Add(ctx, abs); err != nil { + t.Fatal(err) + } + signIn := func(team string) Saved { + t.Helper() + a, err := Authorize(ctx, "fakeco", 1, map[string]string{"where": "work", "team": team}, NewAccount) + if err != nil { + t.Fatal(err) + } + s, err := Finish(ctx, a.Session, "good") + if err != nil { + t.Fatal(err) + } + return s + } + blue, red := signIn("blue"), signIn("red") + if blue.Account != "fakeco" || red.Provider != "fakeco" || !strings.HasPrefix(red.Account, "fakeco#") { + t.Fatalf("accounts %+v %+v", blue, red) + } + ps, err := Providers(ctx) + if err != nil || len(ps) != 1 || len(ps[0].Accounts) != 2 || ps[0].Accounts[0].AccountID != "blue@fake" || ps[0].Accounts[1] != (Account{Key: red.Account, Type: "oauth", AccountID: "red@fake"}) { + t.Fatalf("Providers = %+v, %v", ps, err) + } + if c := Cached(); len(c) != 1 || len(c[0].Accounts) != 2 || c[0].Accounts[1].Key != red.Account || c[0].AccountID != "blue@fake" { + t.Fatalf("Cached = %+v", c) + } + + fetch := func(account string) { + t.Helper() + res, err := Fetch(ctx, FetchRequest{Provider: "fakeco", Account: account, Model: "fake-1", URL: srv.URL + "/v1/chat/completions", Method: "POST", Body: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + io.ReadAll(res.Body) + res.Body.Close() + } + fetch(red.Account) + fetch("") + if len(auths) != 2 || auths[0] != "Bearer fresh-r-red" || auths[1] != "Bearer fresh-r-blue" { + t.Fatalf("sent %v", auths) + } + var saved map[string]map[string]any + b, _ := os.ReadFile(AuthPath()) + json.Unmarshal(b, &saved) + if saved[red.Account]["access"] != "fresh-r-red" || saved["fakeco"]["access"] != "fresh-r-blue" || len(saved) != 2 { + t.Fatalf("saved %v", saved) + } + + // red again: the same account, its sign-in replaced + if again := signIn("red"); again != red { + t.Fatalf("signed in again as %+v, not %+v", again, red) + } + b, _ = os.ReadFile(AuthPath()) + saved = nil + json.Unmarshal(b, &saved) + if len(saved) != 2 || saved[red.Account]["access"] != "stale" { + t.Fatalf("after signing in again %v", saved) + } + + if err := SignOut(ctx, "fakeco", red.Account); err != nil { + t.Fatal(err) + } + if c := Cached(); len(c) != 1 || len(c[0].Accounts) != 1 || c[0].Accounts[0].Key != "fakeco" { + t.Fatalf("after signing red out %+v", c) + } + // with the host stopped, too + signIn("green") + Restart() + if err := SignOut(ctx, "fakeco", ""); err != nil || SignedIn("fakeco") { + t.Fatalf("SignOut all: %v", err) + } +} diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 706ba061..643829d0 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -6,6 +6,8 @@ import ( "errors" "os" "path/filepath" + "slices" + "strings" "sync" "github.com/yetone/magpie/internal/settings" @@ -50,6 +52,19 @@ type Provider struct { AuthType string `json:"authType"` AccountID string `json:"accountId"` Models []Model `json:"models"` + // Accounts are the accounts signed in to it, the one kept under its + // own id first; SignedIn, AuthType and AccountID are that one's. + Accounts []Account `json:"accounts"` +} + +// Account is one account a provider is signed in to: Key is where +// plugin-auth.json keeps it (the provider's id, else id#slot). +type Account struct { + Key string `json:"key"` + Type string `json:"type"` + AccountID string `json:"accountId"` + // Hint tells an account with no id from another: the end of its key. + Hint string `json:"hint,omitempty"` } var ( @@ -120,14 +135,12 @@ func Cached() []Provider { if !on[p.Spec] { continue } - a, ok := auth[p.ID] - p.SignedIn = ok + p.Accounts = accountsOf(auth, p.ID) + p.SignedIn = len(p.Accounts) > 0 p.AuthType = "" - if ok { - p.AuthType = a.Type - if a.AccountID != "" { - p.AccountID = a.AccountID - } + if p.SignedIn { + p.AuthType = p.Accounts[0].Type + p.AccountID = p.Accounts[0].AccountID } out = append(out, p) } @@ -150,6 +163,52 @@ func Cached() []Provider { type storedAuth struct { Type string `json:"type"` AccountID string `json:"accountId"` + Email string `json:"email"` + Key string `json:"key"` + Metadata struct { + Email string `json:"email"` + } `json:"metadata"` +} + +// ProviderOf is the provider an account key is one of. +func ProviderOf(key string) string { + id, _, _ := strings.Cut(key, "#") + return id +} + +// accountsOf are provider's accounts in auth, as the host lists them. +func accountsOf(auth map[string]storedAuth, provider string) []Account { + var out []Account + for k, a := range auth { + if ProviderOf(k) != provider { + continue + } + who := firstNonEmpty(a.AccountID, a.Metadata.Email, a.Email) + acct := Account{Key: k, Type: a.Type, AccountID: who} + if a.Type == "api" && len(a.Key) >= 12 { + acct.Hint = a.Key[len(a.Key)-4:] + } + out = append(out, acct) + } + slices.SortFunc(out, func(a, b Account) int { + switch { + case a.Key == provider: + return -1 + case b.Key == provider: + return 1 + } + return strings.Compare(a.Key, b.Key) + }) + return out +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" } func readAuth() map[string]storedAuth { @@ -162,8 +221,7 @@ func readAuth() map[string]storedAuth { // SignedIn is whether a plugin sign-in is kept for provider. func SignedIn(provider string) bool { - _, ok := readAuth()[provider] - return ok + return len(accountsOf(readAuth(), provider)) > 0 } // Prompt is a question a sign-in method asks before it starts. @@ -214,52 +272,66 @@ type Authorization struct { Method string `json:"method"` } -// Authorize begins an OAuth sign-in. -func Authorize(ctx context.Context, provider string, method int, inputs map[string]string) (Authorization, error) { +// NewAccount is the account a sign-in goes to when it is a new one: the +// provider's own id while nothing is kept there. A sign-in to an account +// already signed in replaces that one's instead. +const NewAccount = "new" + +// Authorize begins an OAuth sign-in to account (a key, or NewAccount). +func Authorize(ctx context.Context, provider string, method int, inputs map[string]string, account string) (Authorization, error) { var a Authorization - err := Call(ctx, "authorize", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &a) + err := Call(ctx, "authorize", map[string]any{"provider": provider, "method": method, "inputs": inputs, "account": account}, &a) return a, err } +// Saved is where a sign-in was kept: the provider (a plugin may sign in +// to another than asked) and the account's key. +type Saved struct { + Provider string `json:"provider"` + Account string `json:"account"` +} + // ErrFailed is a sign-in the plugin says failed. var ErrFailed = errors.New("the sign-in failed") // Finish waits for an OAuth sign-in to finish: an "auto" one on its own, -// a "code" one with the code pasted back. It gives the provider the -// sign-in was saved for. -func Finish(ctx context.Context, session, code string) (string, error) { +// a "code" one with the code pasted back. It gives where the sign-in was +// saved. +func Finish(ctx context.Context, session, code string) (Saved, error) { var r struct { - OK bool `json:"ok"` - Provider string `json:"provider"` + OK bool `json:"ok"` + Saved } if err := Call(ctx, "callback", map[string]any{"session": session, "code": code}, &r); err != nil { - return "", err + return Saved{}, err } if !r.OK { - return "", ErrFailed + return Saved{}, ErrFailed } - return r.Provider, nil + return r.Saved, nil } -// APIKey signs in with a key, as an "api" method does. -func APIKey(ctx context.Context, provider string, method int, inputs map[string]string, key string) (string, error) { +// APIKey signs in to account (a key, or NewAccount) with a key, as an +// "api" method does. +func APIKey(ctx context.Context, provider string, method int, inputs map[string]string, key, account string) (Saved, error) { var r struct { - OK bool `json:"ok"` - Provider string `json:"provider"` + OK bool `json:"ok"` + Saved } - if err := Call(ctx, "apiKey", map[string]any{"provider": provider, "method": method, "inputs": inputs, "key": key}, &r); err != nil { - return "", err + if err := Call(ctx, "apiKey", map[string]any{"provider": provider, "method": method, "inputs": inputs, "key": key, "account": account}, &r); err != nil { + return Saved{}, err } if !r.OK { - return "", ErrFailed + return Saved{}, ErrFailed } - return r.Provider, nil + return r.Saved, nil } -// SignOut forgets the provider's sign-in. -func SignOut(ctx context.Context, provider string) error { +// SignOut forgets one account of the provider's, every one when account +// is "". +func SignOut(ctx context.Context, provider, account string) error { if Running() { - return Call(ctx, "signOut", map[string]any{"provider": provider}, nil) + return Call(ctx, "signOut", map[string]any{"provider": provider, "account": account}, nil) } var m map[string]json.RawMessage b, err := os.ReadFile(AuthPath()) @@ -269,7 +341,11 @@ func SignOut(ctx context.Context, provider string) error { if err := json.Unmarshal(b, &m); err != nil { return err } - delete(m, provider) + for k := range m { + if k == account || account == "" && ProviderOf(k) == provider { + delete(m, k) + } + } b, _ = json.MarshalIndent(m, "", " ") if err := os.WriteFile(AuthPath(), append(b, '\n'), 0o600); err != nil { return err @@ -293,24 +369,26 @@ var ( optGen int64 ) -// LoaderOptions runs the provider's auth loader (once per sign-in and -// host) and gives what it returned. -func LoaderOptions(ctx context.Context, provider string) (Options, error) { +// LoaderOptions runs the provider's auth loader for account (once per +// sign-in and host) and gives what it returned; account "" is the +// provider's first. +func LoaderOptions(ctx context.Context, provider, account string) (Options, error) { + ck := provider + "\x00" + account optMu.Lock() if optGen != generation.Load() { optCache, optGen = map[string]Options{}, generation.Load() } - o, ok := optCache[provider] + o, ok := optCache[ck] optMu.Unlock() if ok { return o, nil } - if err := Call(ctx, "load", map[string]any{"provider": provider}, &o); err != nil { + if err := Call(ctx, "load", map[string]any{"provider": provider, "account": account}, &o); err != nil { return Options{}, err } optMu.Lock() if optGen == generation.Load() { - optCache[provider] = o + optCache[ck] = o } optMu.Unlock() return o, nil diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index b55ad7e7..cf45aa53 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -31,7 +31,7 @@ export const FakePlugin = async ({ client }) => ({ method: "code", callback: async (code) => code === "good" - ? { type: "success", refresh: "r-" + (inputs.team ?? "none"), access: "stale", expires: 0, accountId: "me@fake" } + ? { type: "success", refresh: "r-" + (inputs.team ?? "none"), access: "stale", expires: 0, accountId: (inputs.team ?? "me") + "@fake" } : { type: "failed" }, }), }, @@ -42,7 +42,7 @@ export const FakePlugin = async ({ client }) => ({ async fetch(url, init) { let a = await getAuth() if (a.type === "oauth" && a.expires < Date.now()) { - a = { ...a, access: "fresh", expires: Date.now() + 3600e3 } + a = { ...a, access: "fresh-" + a.refresh, expires: Date.now() + 3600e3 } await client.auth.set({ path: { id: "fakeco" }, body: a }) } const h = new Headers(init.headers) diff --git a/internal/provider/account.go b/internal/provider/account.go index 30efb35a..bc86b78d 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -83,6 +83,7 @@ type Account struct { // plugin is set on a plugin's provider (plugins.go), and transport // carries its requests: the plugin's fetch. plugin *plugin.Provider + pluginKey string // the account's key in plugin-auth.json transport func(req *http.Request) (*http.Response, error) } diff --git a/internal/provider/logins.go b/internal/provider/logins.go index bc8aab73..a21f5876 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -475,6 +475,9 @@ func rememberLogins(force bool) { // Logins lists the remembered accounts of an agent ("" for every one), // the active one flagged. func Logins(agent string) []Login { + if pp, ok := pluginOfAgent(agent); ok { + return pluginLoginList(pp) + } var side []Login switch agent { case "grok": @@ -547,6 +550,9 @@ func Logins(agent string) []Login { // they restart; so does Codex's background app-server, which new Codex // sessions attach to (CodexDaemonStale says when it is). func SwitchLogin(agent, user string) error { + if pp, ok := pluginOfAgent(agent); ok { + return switchPluginLogin(pp, user) + } switch agent { case "grok": return switchGrokLogin(user) @@ -687,6 +693,9 @@ func putClaudeLogin(l savedLogin) error { // ForgetLogin drops a remembered account. The one an agent is signed in to // now can't be forgotten; it would only be remembered again. func ForgetLogin(agent, user string) error { + if pp, ok := pluginOfAgent(agent); ok { + return forgetPluginLogin(pp, user) + } switch agent { case "grok": return forgetGrokLogin(user) diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index 90a27525..2e0652df 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -25,6 +25,9 @@ var savedTokenMu sync.Mutex // SetLoginOn puts a saved account in use beside the agent's own, or takes // it out. The account the agent is signed in to is always in use. func SetLoginOn(agent, user string, on bool) error { + if pp, ok := pluginOfAgent(agent); ok { + return setPluginLoginOn(pp, user, on) + } switch agent { case "grok": return setGrokLoginOn(user, on) @@ -116,6 +119,9 @@ func (p Provider) OwnPaused() bool { // AlsoOn is a signed-in agent's other accounts that are on, each as a // provider of its own, in the order they were saved. func (p Provider) AlsoOn() []Provider { + if p.IsPlugin() { + return pluginAlsoOn(*p.Account.plugin) + } if p.Account != nil && p.Account.Agent == "grok" && p.Account.token == nil { return grokAlsoOn(p) } diff --git a/internal/provider/plugin_accounts.go b/internal/provider/plugin_accounts.go new file mode 100644 index 00000000..67b27694 --- /dev/null +++ b/internal/provider/plugin_accounts.go @@ -0,0 +1,155 @@ +package provider + +import ( + "context" + "strings" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// A plugin's provider is signed in to as many times as the user likes, as +// a built-in subscription is: plugin-auth.json keeps each account's +// sign-in (under the provider's id, then id#slot), logins.json which is +// first and which are on, as it does for the built-ins, the account's key +// in its Home. + +// pluginAgent is what logins.json lists a plugin provider's accounts as. +func pluginAgent(pp plugin.Provider) string { return "plugin:" + pp.ID } + +// pluginLabels names each account: its id, else the end of its key, else +// what it signed in with; a name two share gets the account's slot. +func pluginLabels(pp plugin.Provider) map[string]string { + out := map[string]string{} + seen := map[string]int{} + for _, a := range pp.Accounts { + l := a.AccountID + if l == "" && a.Hint != "" { + l = "API key …" + a.Hint + } + if l == "" { + l = map[string]string{"api": "API key", "oauth": "Signed in"}[a.Type] + } + if l == "" { + l = "Signed in" + } + out[a.Key] = l + seen[strings.ToLower(l)]++ + } + for _, a := range pp.Accounts { + if seen[strings.ToLower(out[a.Key])] > 1 { + if _, slot, ok := strings.Cut(a.Key, "#"); ok { + out[a.Key] += " (" + slot + ")" + } + } + } + return out +} + +type pluginLogin struct { + sideLogin + acct plugin.Account +} + +// pluginLogins are the provider's accounts, the first in use first. What +// plugin-auth.json keeps is the truth: logins.json follows it. +func pluginLogins(pp plugin.Provider) []pluginLogin { + agent := pluginAgent(pp) + labels := pluginLabels(pp) + byKey := map[string]plugin.Account{} + for _, a := range pp.Accounts { + byKey[a.Key] = a + } + loginsMu.Lock() + ls := readLogins() + changed := false + have := map[string]bool{} + keep := ls[:0] + for _, l := range ls { + if l.Agent == agent { + if _, ok := byKey[l.Home]; !ok || have[l.Home] { + changed = true // signed out, or listed twice + continue + } + have[l.Home] = true + if l.User != labels[l.Home] { + l.User, changed = labels[l.Home], true + } + } + keep = append(keep, l) + } + ls = keep + for _, a := range pp.Accounts { + if !have[a.Key] { + ls = append(ls, savedLogin{Agent: agent, User: labels[a.Key], Home: a.Key, On: true, Seen: time.Now().UTC().Truncate(time.Second)}) + changed = true + } + } + if changed { + _ = writeLogins(ls) + } + loginsMu.Unlock() + var out []pluginLogin + for _, l := range sideLogins(agent, "", func(l savedLogin) bool { _, ok := byKey[l.Home]; return ok }) { + out = append(out, pluginLogin{l, byKey[l.saved.Home]}) + } + return out +} + +func pluginSide(pp plugin.Provider) []sideLogin { + var out []sideLogin + for _, l := range pluginLogins(pp) { + out = append(out, l.sideLogin) + } + return out +} + +// pluginOfAgent is the plugin provider the accounts page names by its +// magpie id. +func pluginOfAgent(agent string) (plugin.Provider, bool) { + if strings.HasPrefix(agent, "plugin:") { + id := strings.TrimPrefix(agent, "plugin:") + for _, pp := range plugin.Cached() { + if pp.ID == id { + return pp, true + } + } + return plugin.Provider{}, false + } + return PluginOf(agent) +} + +func pluginLoginList(pp plugin.Provider) []Login { return loginsOf(pluginSide(pp)) } + +func switchPluginLogin(pp plugin.Provider, user string) error { + return switchSideLogin(pluginAgent(pp), user, pluginSide(pp)) +} + +func setPluginLoginOn(pp plugin.Provider, user string, on bool) error { + return setSideLoginOn(pluginAgent(pp), user, on, pluginSide(pp)) +} + +// forgetPluginLogin signs the account out: its sign-in is magpie's own. +func forgetPluginLogin(pp plugin.Provider, user string) error { + var err error + ferr := forgetSideLogin(pluginAgent(pp), user, pluginSide(pp), func(l savedLogin) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err = plugin.SignOut(ctx, pp.ID, l.Home) + }) + if ferr != nil { + return ferr + } + return err +} + +// pluginAlsoOn is the provider's accounts in use behind the first. +func pluginAlsoOn(pp plugin.Provider) []Provider { + var out []Provider + for _, l := range pluginLogins(pp) { + if !l.Active && l.On { + out = append(out, pluginProvider(pp, l.acct, l.User)) + } + } + return out +} diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index c159c7d5..50e2caeb 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -53,12 +53,13 @@ func PluginOf(id string) (plugin.Provider, bool) { return plugin.Provider{}, false } -// pluginAccounts are the plugins' providers signed in to. +// pluginAccounts are the plugins' providers signed in to, each as its +// first account. func pluginAccounts() []Provider { var out []Provider for _, pp := range plugin.Cached() { - if pp.SignedIn { - out = append(out, pluginProvider(pp)) + if ls := pluginLogins(pp); len(ls) > 0 { + out = append(out, pluginProvider(pp, ls[0].acct, ls[0].User)) } } return out @@ -144,17 +145,15 @@ func pluginCatalog(pp plugin.Provider) []catalog.Model { return out } -func pluginProvider(pp plugin.Provider) Provider { +// pluginProvider is the provider as one of its accounts, user as the +// accounts list names it. +func pluginProvider(pp plugin.Provider, acct plugin.Account, user string) Provider { id := PluginID(pp.ID) name := pp.Name if name == "" { name = pp.ID } - user := pp.AccountID - if user == "" { - user = map[string]string{"api": "API key", "oauth": "Signed in"}[pp.AuthType] - } - a := &Account{Agent: "plugin", User: user, Stream: true, plugin: &pp} + a := &Account{Agent: "plugin", User: user, Stream: true, plugin: &pp, pluginKey: acct.Key} a.models = func() []catalog.Model { if cur, ok := PluginOf(id); ok { return pluginCatalog(cur) @@ -174,8 +173,8 @@ func pluginProvider(pp plugin.Provider) Provider { return nil, fmt.Errorf("%s's plugin no longer lists it", name) } a.sign = func(ctx context.Context, req *http.Request, body []byte) error { return nil } - a.transport = func(req *http.Request) (*http.Response, error) { return pluginFetch(pp, req) } - p := Provider{ID: id, Name: name, Account: a} + a.transport = func(req *http.Request) (*http.Response, error) { return pluginFetch(pp, acct.Key, req) } + p := Provider{ID: id, Name: name, Icon: plugin.Icon(pp.Spec, pp.ID), Account: a} for _, m := range pp.Models { switch pluginProtocol(pp.ID, m) { case Chat: @@ -206,7 +205,7 @@ func (p Provider) pluginAPIs(model string) []Protocol { // pluginFetch sends a request the gateway made for a plugin's provider // through the plugin: to the base URL its loader gave (else the model's, // the provider's, the AI SDK package's), with what the loader adds. -func pluginFetch(pp plugin.Provider, req *http.Request) (*http.Response, error) { +func pluginFetch(pp plugin.Provider, account string, req *http.Request) (*http.Response, error) { ctx := req.Context() var body []byte if req.Body != nil { @@ -246,7 +245,7 @@ func pluginFetch(pp plugin.Provider, req *http.Request) (*http.Response, error) if api != model && !codeAssist { body = withModel(body, api) } - o, err := plugin.LoaderOptions(ctx, pp.ID) + o, err := plugin.LoaderOptions(ctx, pp.ID, account) if err != nil { return nil, err } @@ -273,7 +272,7 @@ func pluginFetch(pp plugin.Provider, req *http.Request) (*http.Response, error) } } return plugin.Fetch(ctx, plugin.FetchRequest{ - Provider: pp.ID, Model: api, NPM: m.NPM, URL: url, Method: req.Method, + Provider: pp.ID, Account: account, Model: api, NPM: m.NPM, URL: url, Method: req.Method, Headers: h, Body: body, Session: req.Header.Get(ConversationHeader), }) } diff --git a/internal/provider/pluginsignin.go b/internal/provider/pluginsignin.go index 9df42a3f..4f53d56a 100644 --- a/internal/provider/pluginsignin.go +++ b/internal/provider/pluginsignin.go @@ -14,9 +14,11 @@ import ( // open, then either the plugin notices on its own or the code the page // shows is pasted back (SubmitSignInCallback). SignInStatus follows it, // as it does a built-in sign-in; its Agent is the provider's magpie id. +// The account goes beside those signed in already, or replaces the one it +// is. func StartPluginSignIn(id string, method int, inputs map[string]string) (SignInState, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - a, err := plugin.Authorize(ctx, id, method, inputs) + a, err := plugin.Authorize(ctx, id, method, inputs, plugin.NewAccount) cancel() if err != nil { return SignInState{}, err @@ -72,7 +74,7 @@ func (s *signInFlow) pluginCode(code string) error { return err } -func (s *signInFlow) pluginDone(saved string, err error) { +func (s *signInFlow) pluginDone(saved plugin.Saved, err error) { switch { case errors.Is(err, context.Canceled): s.finish(SignInState{State: "canceled"}) @@ -86,23 +88,25 @@ func (s *signInFlow) pluginDone(saved string, err error) { } // PluginAPIKey signs in to a plugin's provider with a key, as its "api" -// method does; it gives the provider's magpie id. +// method does, beside the accounts signed in already; it gives the +// provider's magpie id. func PluginAPIKey(ctx context.Context, id string, method int, inputs map[string]string, key string) (string, error) { if strings.TrimSpace(key) == "" { return "", errors.New("enter the key") } - saved, err := plugin.APIKey(ctx, id, method, inputs, strings.TrimSpace(key)) + saved, err := plugin.APIKey(ctx, id, method, inputs, strings.TrimSpace(key), plugin.NewAccount) if err != nil { return "", err } - _ = ShowAccount(PluginID(saved)) - return PluginID(saved), nil + _ = ShowAccount(PluginID(saved.Provider)) + return PluginID(saved.Provider), nil } -func pluginUser(id string) string { +// pluginUser is the account signed in to, as the accounts list names it. +func pluginUser(saved plugin.Saved) string { for _, pp := range plugin.Cached() { - if pp.ID == id { - return pp.AccountID + if pp.ID == saved.Provider { + return pluginLabels(pp)[saved.Account] } } return "" diff --git a/internal/provider/pluginsignin_test.go b/internal/provider/pluginsignin_test.go index 2c12c412..514b7e13 100644 --- a/internal/provider/pluginsignin_test.go +++ b/internal/provider/pluginsignin_test.go @@ -66,7 +66,7 @@ func TestPluginSignIn(t *testing.T) { if err := SubmitSignInCallback(st.ID, "good"); err != nil { t.Fatal(err) } - if got := wait(st.ID); got.State != "done" || got.User != "me@fake" { + if got := wait(st.ID); got.State != "done" || got.User != "blue@fake" { t.Fatalf("after the code: %+v", got) } if p, err := Find("fakeco"); err != nil || !p.IsPlugin() { @@ -92,3 +92,136 @@ func TestPluginSignIn(t *testing.T) { t.Fatal(err) } } + +// A plugin's provider takes more than one account, as a built-in +// subscription does: each signed in beside the others, one first and the +// rest on behind it or off, any put first, and removing one signs it out. +func TestPluginAccounts(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) + } + signIn := func(team string) { + t.Helper() + st, err := StartPluginSignIn("fakeco", 1, map[string]string{"where": "work", "team": team}) + if err != nil { + t.Fatal(err) + } + if err := SubmitSignInCallback(st.ID, "good"); err != nil { + t.Fatal(err) + } + if got, _ := SignInStatus(st.ID); got.State != "done" || got.User != team+"@fake" { + t.Fatalf("signing in %s: %+v", team, got) + } + } + users := func() []string { + var out []string + for _, l := range Logins("fakeco") { + u := l.User + if l.Active { + u = "*" + u + } + if !l.On { + u += " (off)" + } + out = append(out, u) + } + return out + } + first := func() string { + t.Helper() + p, err := Find("fakeco") + if err != nil { + t.Fatal(err) + } + return p.Account.User + } + also := func() []string { + p, _ := Find("fakeco") + var out []string + for _, q := range p.AlsoOn() { + out = append(out, q.Account.User+"@"+q.Account.pluginKey) + } + return out + } + + signIn("blue") + signIn("red") + signIn("red") // the same account again: not listed twice + if got := strings.Join(users(), ","); got != "*blue@fake,red@fake" { + t.Fatalf("accounts %s", got) + } + if first() != "blue@fake" { + t.Fatalf("first is %s", first()) + } + if a := also(); len(a) != 1 || !strings.HasPrefix(a[0], "red@fake@fakeco#") { + t.Fatalf("also on %v", a) + } + if err := SetLoginOn("fakeco", "red@fake", false); err != nil { + t.Fatal(err) + } + if a := also(); len(a) != 0 { + t.Fatalf("red is off but %v", a) + } + if err := SetLoginOn("fakeco", "blue@fake", false); err == nil { + t.Fatal("the first account was turned off") + } + if err := SwitchLogin("fakeco", "red@fake"); err != nil { + t.Fatal(err) + } + if got := strings.Join(users(), ","); got != "*red@fake,blue@fake" || first() != "red@fake" { + t.Fatalf("after putting red first: %s, first %s", got, first()) + } + if a := also(); len(a) != 1 || a[0] != "blue@fake@fakeco" { + t.Fatalf("also on %v", a) + } + if err := ForgetLogin("fakeco", "red@fake"); err == nil { + t.Fatal("the first account was removed") + } + if err := ForgetLogin("fakeco", "blue@fake"); err != nil { + t.Fatal(err) + } + if got := strings.Join(users(), ","); got != "*red@fake" { + t.Fatalf("after removing blue: %s", got) + } + for _, pp := range plugin.Cached() { + if len(pp.Accounts) != 1 || !strings.HasPrefix(pp.Accounts[0].Key, "fakeco#") { + t.Fatalf("blue is still signed in: %+v", pp.Accounts) + } + } + + // two keys with nothing to tell them by are told apart by their slot + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k1"); err != nil { + t.Fatal(err) + } + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k2"); err != nil { + t.Fatal(err) + } + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, "sk-long-key-abcd"); err != nil { + t.Fatal(err) + } + us := users() + if len(us) != 4 || us[1] == us[2] || !strings.HasPrefix(us[1], "API key") || us[3] != "API key …abcd" { + t.Fatalf("keys listed as %v", us) + } + + // removing the provider signs every account out + if err := Delete("fakeco"); err != nil { + t.Fatal(err) + } + if plugin.SignedIn("fakeco") || len(Logins("fakeco")) != 0 { + t.Fatalf("still signed in: %v", users()) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f7dfb35b..46946dd0 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -543,7 +543,7 @@ func Delete(id string) error { // a plugin's sign-in is magpie's own: removing it signs out ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return plugin.SignOut(ctx, p.Account.plugin.ID) + return plugin.SignOut(ctx, p.Account.plugin.ID, "") } if _, ok := find(Accounts(), id); ok { f := load() diff --git a/plugins_cli.go b/plugins_cli.go index 5db25e46..25f8f3e4 100644 --- a/plugins_cli.go +++ b/plugins_cli.go @@ -90,7 +90,7 @@ func pluginCmd(args []string) error { if err != nil { return err } - if err := plugin.SignOut(ctx, pp.ID); err != nil { + if err := plugin.SignOut(ctx, pp.ID, ""); err != nil { return err } fmt.Println(green.Render("✓"), "signed out of", pp.Name) @@ -234,17 +234,17 @@ func pluginLogin(ctx context.Context, name, method string) error { fmt.Println(msg) } } - var saved string + var saved plugin.Saved if pp.Methods[m].Type == "api" { key, err := secret("key", pp.Name+" API key: ", false) if err != nil { return err } - if saved, err = plugin.APIKey(ctx, pp.ID, m, inputs, key); err != nil { + if saved, err = plugin.APIKey(ctx, pp.ID, m, inputs, key, plugin.NewAccount); err != nil { return err } } else { - a, err := plugin.Authorize(ctx, pp.ID, m, inputs) + a, err := plugin.Authorize(ctx, pp.ID, m, inputs, plugin.NewAccount) if err != nil { return err } @@ -271,7 +271,7 @@ func pluginLogin(ctx context.Context, name, method string) error { return err } } - fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+provider.PluginID(saved)+"/")) + fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+provider.PluginID(saved.Provider)+"/")) return nil }