plugins: a plugin's provider takes more than one account, as a built-in subscription does (the user: 插件当然要支持多账号了) — a plugin kept one sign-in per provider, and signing in again replaced it. Now each sign-in goes beside the others: plugin-auth.json keeps the first under the provider's id, as OpenCode does (so what is signed in already stays the first account), the rest under id#slot; logins.json lists them as it lists the built-ins' accounts, so the editor shows them all, one first, the others ticked on behind it or off, any made first, any removed (which signs it out). The host runs every request in its account's scope, so a plugin's getAuth, client.auth.set (a refreshed token), loader and provider.models see that account alone; a sign-in to an account already there (the same id, else the same secret) replaces that one's rather than listing it twice. The gateway spreads and fails over across the accounts on, as for the built-ins. Accounts with no id are told apart by the end of their key ("API key …abcd"), else by their slot. Also on the Devin page: the header shows the plugin's icon, and Cognition's opencode-devin is off the market (its gateway answers 404 model_not_found for models and chat). TestPluginAccounts (plugin: per-account requests and refreshes, dedupe, sign-out; provider: first, on/off, switch, forget, labels), TestPluginProvider (gateway: a 429 on the first account goes on the second) and plugin-signin.test.cjs (add a second account in the editor, make it first; fails without the change) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-signin, plugin-market, old-webkit, click-scroll pass.

This commit is contained in:
yetone committed 2026-09-30 19:58:22 +08:00
1 parent ef4edf8c58
commit b63a0f1fd5
20 files changed
+785 -144

No files matched your search

+28 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 }); },
}));
}
+1
View File
@@ -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.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。",
+4 -1
View File
@@ -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{}
+36 -10
View File
@@ -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, []);
});
}
+1
View File
@@ -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"`
+161 -48
View File
@@ -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#<slot>. 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
},
}
-2
View File
@@ -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。" } }
]
+97 -9
View File
@@ -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)
}
}
+116 -38
View File
@@ -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
+2 -2
View File
@@ -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)
+1
View File
@@ -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)
}
+9
View File
@@ -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)
+6
View File
@@ -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)
}
+155
View File
@@ -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
}
+13 -14
View File
@@ -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),
})
}
+13 -9
View File
@@ -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 ""
+134 -1
View File
@@ -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())
}
}
+1 -1
View File
@@ -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()
+5 -5
View File
@@ -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)+"/<model>"))
fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+provider.PluginID(saved.Provider)+"/<model>"))
return nil
}