plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account.

This commit is contained in:
yetone committed 2026-09-30 16:44:32 +08:00
1 parent e0e93c2ec5
commit a31f97b30d
27 files changed
+3887 -31

No files matched your search

+24
View File
@@ -138,6 +138,27 @@ first day, `qianfan-token-plan`, is taken too. The plans serve no model list,
so the preset carries their documented models; pay as you go serves its own
at `/v2/models`.
### Plugins
A subscription magpie doesn't sign in to itself can come from an
[OpenCode](https://opencode.ai) provider plugin: the npm packages OpenCode
users install to sign in to a plan (their `auth` hook) work in magpie as they
do there. magpie runs them on [Bun](https://bun.sh), downloaded the first
time a plugin needs it, and the plugin signs in, lists the models and makes
each request; magpie serves them to agents like any provider's.
```sh
magpie plugin add opencode-gemini-auth # an npm package, or a path to a plugin of your own
magpie plugin # the plugins, what each signs in to, and whether you are
magpie plugin login google-plugin # its sign-in: the method, its questions, the browser or a key
magpie plugin logout google-plugin
magpie plugin off opencode-gemini-auth # on brings it back; rm removes it; update updates them all
```
A provider id magpie already has (google, openai, anthropic) is
`<id>-plugin`. In the app, Settings → Plugins adds and removes them, and
the providers they sign in to are in Add provider → From plugins.
### Routing groups
A routing group is several models, from one provider or many, that an agent
@@ -485,6 +506,9 @@ stdin.
- `~/.config/magpie/profiles.json` — saved profiles
- `~/.config/magpie/providers.json` — your providers, keys included (0600)
- `~/.config/magpie/stash.json` — values magpie replaced, restored on switch-back
- `~/.config/magpie/plugins.json`, `plugins/` — the plugins added, and their packages
- `~/.config/magpie/plugin-auth.json` — the plugins' sign-ins (0600)
- `~/.cache/magpie/bun/` — the Bun plugins run on
- `~/.cache/magpie/models.json` — models.dev catalog (OpenCode's cache at
`~/.cache/opencode/models.json` is used when present)
- `~/.cache/magpie/models/<provider>.json` — model lists fetched from vendors
+4 -1
View File
@@ -1338,10 +1338,13 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
}
}
}
if p.IsPlugin() {
req.Header.Set(provider.ConversationHeader, conversationID(in, body))
}
if err := p.Sign(ctx, req, to, body); err != nil {
return nil, err
}
return s.client.Do(req)
return p.Do(s.client, req)
}
// fromClaudeCode is a request Claude Code sent, by the User-Agent it gives
+121
View File
@@ -0,0 +1,121 @@
package gateway
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
// A plugin's provider signed in to is a provider like the others: each of
// its models is spoken to on the API its AI SDK package speaks, at the
// base URL the plugin's loader gives, and the plugin's fetch carries the
// request with the sign-in it keeps.
func TestPluginProvider(t *testing.T) {
bun, err := exec.LookPath("bun")
if err != nil {
t.Skip("no bun on PATH")
}
fresh(t)
t.Setenv("MAGPIE_BUN", bun)
t.Cleanup(plugin.Settle)
type seen struct {
path, auth, model, body string
}
var mu sync.Mutex
var got []seen
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)})
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
switch {
case r.URL.Path == "/v1/chat/completions":
fmt.Fprint(w, `data: {"id":"c","object":"chat.completion.chunk","model":"fake-1","choices":[{"index":0,"delta":{"role":"assistant","content":"hi from chat"},"finish_reason":null}]}`+"\n\n")
fmt.Fprint(w, `data: {"id":"c","object":"chat.completion.chunk","model":"fake-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n")
case r.URL.Path == "/v1/messages":
for _, e := range []string{
`{"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"fake-claude","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}`,
`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi from claude"}}`,
`{"type":"content_block_stop","index":0}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
`{"type":"message_stop"}`,
} {
fmt.Fprintf(w, "data: %s\n\n", e)
}
case strings.HasPrefix(r.URL.Path, "/v1/models/"):
fmt.Fprint(w, `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"hi from gemini"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2}}`+"\n\n")
default:
w.WriteHeader(404)
}
}))
defer up.Close()
t.Setenv("FAKE_BASE", up.URL+"/v1")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
if _, err := plugin.Add(ctx, abs); err != nil {
t.Fatal(err)
}
if _, err := plugin.Providers(ctx); err != nil {
t.Fatal(err)
}
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 {
t.Fatal(err)
}
p, err := provider.Find("fakeco")
if err != nil || !p.IsPlugin() || p.Name != "FakeCo" {
t.Fatalf("Find = %+v, %v", p, err)
}
for model, want := range map[string]provider.Protocol{"fake-1": provider.Chat, "fake-claude": provider.Anthropic, "fake-gemini": provider.CodeAssist} {
if apis := p.APIs(model); len(apis) != 1 || apis[0] != want {
t.Fatalf("APIs(%s) = %v", model, apis)
}
}
s := New()
for _, c := range []struct{ model, text, path string }{
{"fakeco/fake-1", "hi from chat", "/v1/chat/completions"},
{"fakeco/fake-claude", "hi from claude", "/v1/messages"},
{"fakeco/fake-gemini", "hi from gemini", "/v1/models/fake-gemini:streamGenerateContent?alt=sse"},
} {
code, body := postAs(t, s, "", `{"model":"`+c.model+`","messages":[{"role":"user","content":"hello"}]}`)
if code != 200 || !strings.Contains(body, c.text) {
t.Fatalf("%s: %d %s", c.model, code, body)
}
mu.Lock()
last := got[len(got)-1]
mu.Unlock()
model := strings.TrimPrefix(c.model, "fakeco/")
if last.path != c.path || last.auth != "Bearer k1" || last.model != model || !strings.Contains(last.body, "hello") {
t.Fatalf("%s reached upstream as %+v", c.model, last)
}
if strings.Contains(last.body, `"request"`) {
t.Fatalf("%s kept Code Assist's envelope: %s", c.model, last.body)
}
}
if err := plugin.SignOut(ctx, "fakeco"); err != nil {
t.Fatal(err)
}
if _, err := provider.Find("fakeco"); err == nil {
t.Fatal("fakeco is still a provider after signing out")
}
}
+4
View File
@@ -1592,6 +1592,10 @@ input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
:root[data-theme="dark"] .warm-at input.at { color-scheme: dark; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .warm-at input.at { color-scheme: dark; } }
@media (max-width: 520px) { .proxy-segs { flex-wrap: wrap; justify-content: flex-end; } }
/* Settings → Plugins: what a plugin signs in to, or why it didn't load, said whole */
.row.pref.plugin-row .sub, .row.pref.plugin-add .sub { white-space: normal; }
.row.pref.plugin-row.off .name { color: var(--muted); }
.row.pref.plugin-add .val { flex-wrap: wrap; justify-content: flex-end; }
.prefs .val { display: inline-flex; align-items: center; gap: 2px; margin-left: auto; min-width: 0; max-width: 60%; flex: none; }
.prefs .val code { font-size: 12px; color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; -webkit-user-select: text; padding: 0 6px; }
.prefs .val .text { padding: 3px 6px; }
+269 -23
View File
@@ -2768,6 +2768,14 @@ function renderAdd() {
const w = subs.find((x) => signing?.agent === x.agent);
if (w) tiles.append(renderSigning(w));
}
const plugged = pluginSubs().filter((x) => !f || x.name.toLowerCase().includes(f) || x.agent.includes(f) || "plugin".includes(f));
if (plugged.length) {
any = true;
const grid = section("From plugins", "signed in by an OpenCode plugin");
for (const x of plugged) grid.append(subTile(x));
const w = plugged.find((x) => signing?.agent === x.agent);
if (w) tiles.append(renderSigning(w));
}
const gone = (providers.excluded || []).filter((x) => x.quiet && x.provider && (!f || x.agentName.toLowerCase().includes(f) || x.agent.includes(f)));
if (gone.length) {
any = true;
@@ -4626,7 +4634,17 @@ const SUBS = [
// accounts can also come from another tool's export (Antigravity Cockpit, Antigravity Manager, CLIProxyAPI)
{ agent: "antigravity", name: "Antigravity", icon: "antigravity-color", plans: "Google AI Pro · Ultra · free", risk: true, importable: true },
];
const subOf = (agent) => SUBS.find((x) => x.agent === agent);
const subOf = (agent) => SUBS.find((x) => x.agent === agent) || pluginSubs().find((x) => x.agent === agent);
// pluginSubs: the providers OpenCode plugins sign in to (Settings →
// Plugins), as subscriptions like the built-in ones. The plugin, not
// 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,
get plans() { return t("from the plugin {spec}", { spec: x.spec }); },
}));
}
// importSay: what the import of an app's accounts says — where its files
// come from, and who they are checked with. A ChatGPT or Claude sign-in is
@@ -4656,6 +4674,7 @@ let justAdded = ""; // the account that just came in, to greet it
async function startSignIn(agent, risky, site) {
if (signingOpen()) api("signin/" + signing.id + "/cancel", {}).catch(() => {});
if (subOf(agent)?.plugin) return startPluginSignIn(subOf(agent));
// an account Google may suspend is added only once that is said
if (subOf(agent)?.risk && !risky) {
signing = { agent, state: "risk" };
@@ -4694,27 +4713,162 @@ async function followSignIn(id) {
if (signing.state !== st.state || signing.url !== st.url) { signing = { ...st, site: signing.site }; renderProviders(); }
continue;
}
if (st.state === "done") {
signing = null;
justAdded = st.user;
delete loginUsage[st.agent]; // what was fetched before has nothing on the new account
providers = await api("providers");
const p = providers.providers.find((x) => x.account?.agent === st.agent);
if (p) { editing = p.id; draft = null; adding = false; presetQuery = ""; }
renderProviders();
// signed in but listed nowhere (#155): say so rather than "added"
if (!p) status(t("{user} signed in, but magpie can't list it — please report this", { user: st.user }), "err");
else status(st.using ? t("Signed in as {user}", { user: st.user }) : t("{user} added — switch to it any time", { user: st.user }), "ok");
state = await api("state");
renderAgents();
setTimeout(() => { justAdded = ""; }, 2000);
return;
}
if (st.state === "done") return signedIn(st);
signing = st.state === "canceled" ? null : { ...st, site: signing.site };
renderProviders();
}
}
// signedIn: a sign-in done — the account opened in the editor.
async function signedIn(st) {
signing = null;
justAdded = st.user;
delete loginUsage[st.agent]; // what was fetched before has nothing on the new account
providers = await api("providers");
const p = providers.providers.find((x) => x.account?.agent === st.agent);
if (p) { editing = p.id; draft = null; adding = false; presetQuery = ""; }
renderProviders();
const who = st.user || subOf(st.agent)?.name || st.agent;
// signed in but listed nowhere (#155): say so rather than "added"
if (!p) status(t("{user} signed in, but magpie can't list it — please report this", { user: who }), "err");
else status(st.using ? t("Signed in as {user}", { user: who }) : t("{user} added — switch to it any time", { user: who }), "ok");
state = await api("state");
renderAgents();
setTimeout(() => { justAdded = ""; }, 2000);
}
// startPluginSignIn: a plugin's provider signed in to as OpenCode's
// `auth login` does — the way to sign in, the questions that way asks,
// then the browser (and the code its page shows, pasted back) or a key.
async function startPluginSignIn(sub, method, inputs = {}) {
const ms = sub.plugin.methods || [];
if (!ms.length) {
signing = { agent: sub.agent, state: "failed", error: t("The plugin has no way to sign in to {name}", { name: sub.name }) };
return renderProviders();
}
if (method == null) {
if (ms.length > 1) {
signing = { agent: sub.agent, state: "method" };
return renderProviders();
}
method = 0;
}
signing = { agent: sub.agent, method, inputs, state: "starting" };
renderProviders();
try {
const r = await api("plugin-signin/prompt", { provider: sub.pid, method, inputs });
if (signing?.agent !== sub.agent) return;
if (r.prompt) {
signing = { agent: sub.agent, method, inputs, prompt: r.prompt, state: "prompt" };
return renderProviders();
}
if (ms[method].type === "api") {
signing = { agent: sub.agent, method, inputs, state: "key" };
return renderProviders();
}
signing = await api("plugin-signin", { provider: sub.pid, method, inputs });
signing.method = method;
if (web && signing.url) api("open", { url: signing.url });
renderProviders();
followSignIn(signing.id);
} catch (e) {
if (signing?.agent !== sub.agent) return;
signing = { agent: sub.agent, method, state: "failed", error: e.message };
renderProviders();
}
}
// pluginAnswer: one of a sign-in method's questions answered, checked by
// the plugin before the next is asked.
async function pluginAnswer(sub, value) {
const flow = signing;
try {
const r = await api("plugin-signin/prompt", { provider: sub.pid, method: flow.method, inputs: flow.inputs, key: flow.prompt.key, value });
if (signing !== flow) return;
if (r.error) {
signing = { ...flow, error: r.error, value };
return renderProviders();
}
startPluginSignIn(sub, flow.method, r.inputs);
} catch (e) {
if (signing === flow) { signing = { ...flow, error: e.message, value }; renderProviders(); }
}
}
// pluginKey: an "api" method's key, which the plugin keeps.
async function pluginKey(sub, key) {
const flow = signing;
signing = { ...flow, busy: true };
renderProviders();
try {
const st = await api("plugin-signin", { provider: sub.pid, method: flow.method, inputs: flow.inputs, key });
if (signing?.agent === sub.agent) signedIn(st);
} catch (e) {
if (signing?.agent === sub.agent) { signing = { ...flow, error: e.message }; renderProviders(); }
}
}
// renderPluginAsk: a plugin sign-in's step before the browser — the way
// to sign in, a question, or the key.
function renderPluginAsk(sub) {
const box = el("div", "signing plugin-ask");
const tt = el("span", "tt");
box.append(tt);
const close = el("button", "text", t("Cancel"));
close.onclick = cancelSignIn;
if (signing.state === "method") {
tt.append(el("span", "n", t("How do you sign in to {name}?", { name: sub.name })),
el("span", "s", t("The plugin {spec} signs in and sends {name}'s requests; magpie only passes them on.", { spec: sub.plugin.spec, name: sub.name })));
box.append(close);
sub.plugin.methods.forEach((m, i) => {
const b = el("button", "text primary", m.label || t(m.type === "api" ? "API key" : "Browser"));
b.dataset.method = String(i);
b.onclick = () => startPluginSignIn(sub, i);
box.append(b);
});
return box;
}
const q = signing.prompt;
const flow = signing;
const form = el("form", "callback-form");
const why = el("span", "s why", flow.error || "");
if (flow.state === "prompt" && q.type === "select") {
tt.append(el("span", "n", q.message), why);
box.append(close);
for (const o of q.options || []) {
const b = el("button", "text primary", o.label);
if (o.hint) b.title = o.hint;
b.onclick = () => pluginAnswer(sub, o.value);
box.append(b);
}
return box;
}
const key = flow.state === "key";
const label = key ? t("{name} API key", { name: sub.name }) : q.message;
tt.append(el("span", "n", label));
const inp = input(flow.value || "", key ? "" : (q.placeholder || ""), key ? "password" : "text");
inp.setAttribute("aria-label", label);
inp.autocomplete = "off";
inp.disabled = !!flow.busy;
const go = el("button", "text primary", t(key ? "Sign in" : "Next"));
go.type = "submit";
go.disabled = !!flow.busy;
inp.onkeydown = (e) => e.stopPropagation();
const submit = (e) => {
e.preventDefault();
if (signing !== flow || flow.busy) return;
if (key) pluginKey(sub, inp.value);
else pluginAnswer(sub, inp.value);
};
form.onsubmit = submit;
go.onclick = submit;
form.append(inp, go);
tt.append(form, why);
box.append(close);
setTimeout(() => { if (inp.isConnected && !inp.disabled) inp.focus({ preventScroll: true }); });
return box;
}
// renderStepPlan: a StepFun provider's platform sign-in, which the user
// makes in their own browser and brings here with magpie's bookmarklet
function renderStepPlan(sp) {
@@ -4818,12 +4972,13 @@ function renderSigning(sub) {
return box;
}
if (signing.state === "import" || signing.state === "importing" || signing.state === "imported") return renderLoginImport(sub);
if (signing.state === "method" || signing.state === "prompt" || signing.state === "key") return renderPluginAsk(sub);
if (signing.state === "failed") {
box.append(el("span", "mark", "!"));
tt.append(el("span", "n", t("Sign-in didn't finish")), el("span", "s", signing.error || ""));
box.append(tt);
const again = el("button", "text primary", t("Try again"));
again.onclick = () => startSignIn(sub.agent, true, signing.site);
again.onclick = () => sub.plugin ? startPluginSignIn(sub, signing.method) : startSignIn(sub.agent, true, signing.site);
const close = el("button", "text", t("Cancel"));
close.onclick = cancelSignIn;
box.append(close, again);
@@ -4840,7 +4995,9 @@ function renderSigning(sub) {
return box;
}
tt.append(el("span", "n", t("Finish signing in to {name} in your browser", { name: sub.name })),
el("span", "s", signing.code && sub.agent === "factory" ? t("magpie opened Factory's sign-in page. Check it shows this code and confirm it; the account shows up here as soon as you're done.")
el("span", "s", signing.state === "starting" ? t("Starting the sign-in…")
: sub.plugin ? (signing.instructions || (signing.pasteCode ? t("magpie opened the sign-in page. Paste the code it shows below.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done.")))
: signing.code && sub.agent === "factory" ? t("magpie opened Factory's sign-in page. Check it shows this code and confirm it; the account shows up here as soon as you're done.")
: signing.code ? t("magpie opened GitHub's device page. Enter this code there; the account shows up here as soon as you're done.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done.")));
if (signing.code) {
const code = el("span", "devcode");
@@ -4857,12 +5014,13 @@ function renderSigning(sub) {
acts.append(open, cp);
tt.append(acts);
}
if (signing.pasteCallback) {
if (signing.pasteCallback || signing.pasteCode) {
const flow = signing;
tt.append(el("span", "s", t("If the browser cannot return to magpie, paste its final callback URL here.")));
const what = flow.pasteCode ? t("Code") : t("Callback URL");
if (!flow.pasteCode) tt.append(el("span", "s", t("If the browser cannot return to magpie, paste its final callback URL here.")));
const form = el("form", "callback-form");
const url = input(flow.callbackURL || "", t("Callback URL"));
url.setAttribute("aria-label", t("Callback URL"));
const url = input(flow.callbackURL || "", what);
url.setAttribute("aria-label", what);
url.autocomplete = "off";
url.disabled = !!flow.callbackSubmitted || !!flow.callbackSubmitting;
const submit = el("button", "text primary", t("Finish sign-in"));
@@ -8072,6 +8230,7 @@ function renderSettings() {
renderImages(s, keep);
renderRedact(s, keep);
renderLAN(s);
renderPlugins();
renderSync();
const about = $("#about");
@@ -8134,6 +8293,93 @@ function renderSessionTerminal(s, keep) {
select.onchange = () => savePrefs({ ...keep, sessionTerminal: select.value === "system" ? "" : select.value });
}
// renderPlugins: OpenCode's provider plugins, each signing in to a
// subscription magpie doesn't (or no longer does) itself. They are other
// people's code: magpie runs them on Bun and passes their requests on.
let pluginsView = null;
let pluginsBusy = "";
async function renderPlugins(v) {
const box = $("#pluginsList");
if (!box) return;
if (v) pluginsView = v;
else if (!pluginsView) {
pluginsView = { plugins: [], loading: true };
api("plugins").then((x) => renderPlugins(x), (e) => renderPlugins({ plugins: [], error: e.message }));
}
v = pluginsView;
box.replaceChildren();
const run = async (op, body, busy) => {
pluginsBusy = busy;
renderPlugins();
try {
const r = await api("plugins/" + op, body);
pluginsBusy = "";
renderPlugins(r);
providers = await api("providers");
renderProviders();
} catch (e) {
pluginsBusy = "";
renderPlugins();
status(e.message, "err");
}
};
for (const p of v.plugins) {
const r = el("div", "row pref plugin-row" + (p.off ? " off" : ""));
const who = el("div", "who");
who.append(el("div", "name", p.spec));
const sub = el("div", "sub", p.off ? t("Off")
: p.error ? t("Didn't load: {error}", { error: p.error })
: p.providers.length ? t("Signs in to {names}", { names: p.providers.join(t(", ")) })
: v.loading ? t("Loading…") : t("Signs in to nothing magpie can use"));
if (p.error && !p.off) sub.classList.add("bad");
who.append(sub);
const val = el("div", "val");
const onoff = el("button", "text", t(p.off ? "Switch on" : "Switch off"));
onoff.onclick = () => run("off", { spec: p.spec, off: !p.off }, p.spec);
const rm = el("button", "text quiet", t("Remove"));
rm.title = t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
rm.onclick = () => run("remove", { spec: p.spec }, p.spec);
onoff.disabled = rm.disabled = !!pluginsBusy;
val.append(onoff, rm);
r.append(who, val);
box.append(r);
}
// a new one: an npm package or a file on this computer
const r = el("div", "row pref plugin-add");
const who = el("div", "who");
who.append(el("div", "name", t("Add a plugin")));
who.append(el("div", "sub", pluginsBusy === "+" ? (v.bun ? t("Installing…") : t("Downloading Bun {v}, which plugins run on, then installing…", { v: v.bunVersion || "" }))
: v.error ? t("Couldn't ask the plugins: {error}", { error: v.error })
: t("An OpenCode provider plugin — its npm package or a path. Plugins are other people's code: they sign in and make the requests.")));
const val = el("div", "val");
const form = el("form", "proxy-segs");
const spec = input("", "opencode-gemini-auth");
spec.className = "proxy";
spec.setAttribute("aria-label", t("Plugin"));
spec.autocomplete = "off";
spec.spellcheck = false;
spec.onkeydown = (e) => e.stopPropagation();
const add = el("button", "text primary", t("Add"));
add.type = "submit";
add.disabled = spec.disabled = !!pluginsBusy;
form.onsubmit = add.onclick = (e) => {
e.preventDefault();
if (!spec.value.trim() || pluginsBusy) return;
run("add", { spec: spec.value.trim() }, "+");
};
form.append(spec, add);
val.append(form);
if (v.plugins.length) {
const up = el("button", "text", t(pluginsBusy === "update" ? "Updating…" : "Update all"));
up.title = t("Installs the newest version of each plugin");
up.disabled = !!pluginsBusy;
up.onclick = () => run("update", {}, "update");
val.append(up);
}
r.append(who, val);
box.append(r);
}
// renderSync: the Settings page's sync and backup — WebDAV keeping the
// setup the same on every computer, and a sealed file to carry by hand.
// One of the three opens a form below its row at a time.
+27
View File
@@ -648,6 +648,33 @@ const I18N = {
"Copy the gateway URL": "复制网关地址",
"Gateway URL": "网关地址",
// sync and backup
// OpenCode's provider plugins: Settings → Plugins, the add sheet's "From plugins"
"Plugins": "插件",
"From plugins": "来自插件",
"signed in by an OpenCode plugin": "由 OpenCode 插件登录",
"from the plugin {spec}": "来自插件 {spec}",
"The plugin has no way to sign in to {name}": "插件没有提供登录 {name} 的方式",
"How do you sign in to {name}?": "用哪种方式登录 {name}?",
"The plugin {spec} signs in and sends {name}'s requests; magpie only passes them on.": "由插件 {spec} 登录并发出 {name} 的请求,magpie 只负责转交。",
"Browser": "浏览器",
"{name} API key": "{name} API Key",
"Next": "下一步",
"Starting the sign-in…": "正在开始登录…",
"magpie opened the sign-in page. Paste the code it shows below.": "magpie 已打开登录页面,请把页面上显示的代码粘贴到下面。",
"Didn't load: {error}": "没有加载成功:{error}",
"Signs in to {names}": "可登录 {names}",
"Signs in to nothing magpie can use": "没有 magpie 能用的登录",
"Removes the plugin and what it installed; its sign-ins are kept until you sign out": "移除插件及其安装的内容;它的登录会保留,直到你退出登录",
"Add a plugin": "添加插件",
"Downloading Bun {v}, which plugins run on, then installing…": "正在下载插件运行所需的 Bun {v},然后安装…",
"Couldn't ask the plugins: {error}": "无法读取插件:{error}",
"An OpenCode provider plugin — its npm package or a path. Plugins are other people's code: they sign in and make the requests.": "OpenCode 的 provider 插件,填 npm 包名或本地路径。插件是第三方代码:由它登录并发出请求。",
"Plugin": "插件",
"Switch on": "启用",
"Switch off": "停用",
"Sign in": "登录",
"Loading…": "加载中…",
"Installs the newest version of each plugin": "把每个插件更新到最新版本",
"Sync and backup": "同步与备份",
"WebDAV sync": "WebDAV 同步",
"Keeps providers, settings, profiles, agents' models and the library the same on every computer": "让每台电脑上的供应商、设置、Profile、各 agent 的模型和资源库保持一致",
+2
View File
@@ -252,6 +252,8 @@
<div class="list prefs" id="imagesList"></div>
<div class="row-head"><span class="label" data-t>Privacy</span></div>
<div class="list prefs" id="redactList"></div>
<div class="row-head"><span class="label" data-t>Plugins</span></div>
<div class="list prefs" id="pluginsList"></div>
<div class="row-head"><span class="label" data-t>Sync and backup</span></div>
<div class="list prefs" id="syncList"></div>
<div class="row-head"><span class="label" data-t>About</span></div>
+233
View File
@@ -0,0 +1,233 @@
package gui
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
// OpenCode's provider plugins (internal/plugin): the providers they sign
// in to are subscriptions in the add sheet, and Settings → Plugins adds,
// updates and removes them.
// pluginSubJSON is a plugin's provider as the add sheet lists it.
type pluginSubJSON struct {
ID string `json:"id"` // magpie's
PID string `json:"pid"` // OpenCode's, which the plugin knows it by
Name string `json:"name"`
Icon string `json:"icon"`
Spec string `json:"spec"` // the plugin
Methods []plugin.Method `json:"methods"`
SignedIn bool `json:"signedIn"`
Models int `json:"models"`
}
// pluginIcon is the vendor's icon for the providers OpenCode names, a
// plain one for the rest.
func pluginIcon(id string) string {
switch id {
case "github-copilot", "github-copilot-enterprise":
return "githubcopilot"
case "anthropic":
return "claude-color"
case "openai":
return "openai"
case "google", "google-vertex":
return "gemini-color"
case "qwen", "alibaba":
return "qwen-color"
case "moonshotai", "kimi-for-coding":
return "kimi"
}
return "generic"
}
func pluginSubs() []pluginSubJSON {
out := []pluginSubJSON{}
for _, pp := range plugin.Cached() {
out = append(out, pluginSubJSON{
ID: provider.PluginID(pp.ID), PID: pp.ID, Name: pp.Name, Icon: pluginIcon(pp.ID), Spec: pp.Spec,
Methods: pp.Methods, SignedIn: pp.SignedIn, Models: len(pp.Models),
})
}
return out
}
// pluginEntryJSON is a plugin as Settings → Plugins lists it.
type pluginEntryJSON struct {
plugin.Entry
Error string `json:"error,omitempty"` // why it didn't load
Providers []string `json:"providers"` // the names of those it signs in to
}
type pluginsJSON struct {
Plugins []pluginEntryJSON `json:"plugins"`
Bun bool `json:"bun"` // Bun is here; adding the first plugin downloads it otherwise
BunVer string `json:"bunVersion"`
Error string `json:"error,omitempty"` // the plugins couldn't be asked
}
func pluginsState(ctx context.Context) pluginsJSON {
s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunVersion}
l := plugin.Load()
errs := map[string]string{}
names := map[string][]string{}
if len(l.Plugins) > 0 && (plugin.Running() || plugin.HasBun()) {
loaded, err := plugin.Plugins(ctx)
if err != nil {
s.Error = err.Error()
}
for _, p := range loaded {
errs[p.Spec] = p.Error
}
if ps, err := plugin.Providers(ctx); err == nil {
for _, p := range ps {
names[p.Spec] = append(names[p.Spec], p.Name)
}
}
}
for _, e := range l.Plugins {
j := pluginEntryJSON{Entry: e, Error: errs[e.Spec], Providers: names[e.Spec]}
if j.Providers == nil {
j.Providers = []string{}
}
s.Plugins = append(s.Plugins, j)
}
return s
}
func pluginRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/plugins", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
writeJSON(rw, pluginsState(ctx))
})
// add, remove, update, turn on or off: each answers with the list
mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Spec string
Off bool
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil && err != io.EOF {
fail(rw, err)
return
}
// a first plugin downloads Bun, and npm installs it
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
var err error
switch r.PathValue("op") {
case "add":
_, err = plugin.Add(ctx, in.Spec)
case "remove":
err = plugin.Remove(ctx, in.Spec)
case "update":
err = plugin.Update(ctx)
case "off":
err = plugin.SetOff(in.Spec, in.Off)
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, pluginsState(ctx))
})
// signing in to a plugin's provider: the method's questions one at a
// time, then a browser (OAuth) or a key
mux.HandleFunc("POST /api/plugin-signin/prompt", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Provider string
Method int
Inputs map[string]string
Key string // the question answered, with Value: checked first
Value string
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
if in.Inputs == nil {
in.Inputs = map[string]string{}
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if in.Key != "" {
msg, err := plugin.Validate(ctx, in.Provider, in.Method, in.Key, in.Value)
if err != nil {
fail(rw, err)
return
}
if msg != "" {
writeJSON(rw, map[string]any{"error": msg})
return
}
in.Inputs[in.Key] = in.Value
}
q, err := plugin.NextPrompt(ctx, in.Provider, in.Method, in.Inputs)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]any{"prompt": q, "inputs": in.Inputs})
})
mux.HandleFunc("POST /api/plugin-signin", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Provider string
Method int
Inputs map[string]string
Key string // an "api" method's key
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
pp, ok := pluginProviderByID(in.Provider)
if !ok || in.Method < 0 || in.Method >= len(pp.Methods) {
fail(rw, errNoPluginMethod)
return
}
if pp.Methods[in.Method].Type == "api" {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
id, err := provider.PluginAPIKey(ctx, in.Provider, in.Method, in.Inputs, in.Key)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, provider.SignInState{Agent: id, State: "done", User: pp.Name})
return
}
st, err := provider.StartPluginSignIn(in.Provider, in.Method, in.Inputs)
if err != nil {
fail(rw, err)
return
}
if st.URL != "" && w != nil {
w.OpenURL(st.URL)
}
writeJSON(rw, st)
})
}
type pluginErr string
func (e pluginErr) Error() string { return string(e) }
const errNoPluginMethod = pluginErr("the plugin has no such way to sign in; reopen the add sheet")
func pluginProviderByID(id string) (plugin.Provider, bool) {
for _, pp := range plugin.Cached() {
if pp.ID == id {
return pp, true
}
}
return plugin.Provider{}, false
}
+10
View File
@@ -165,6 +165,8 @@ type providersJSON struct {
// signed in to after Codex was switched to another; "" when none is
// left behind (provider.CodexDaemonStale).
CodexDaemon string `json:"codexDaemon,omitempty"`
// Plugins are the providers the plugins sign in to, for the add sheet
Plugins []pluginSubJSON `json:"plugins"`
// Moved is the agents the change moved off models it stopped serving
// (agent.Reseat), for the page to say so.
Moved []agent.Move `json:"moved,omitempty"`
@@ -269,6 +271,12 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
out.Account.Name, out.Account.Icon = "Command Code", "commandcode"
}
out.Account.Logins = provider.Logins(a.Agent)
if pp, ok := provider.PluginOf(p.ID); ok && p.IsPlugin() {
// a plugin's sign-in: named for the provider it signs in to,
// the page following it by the provider's id
out.Account.Agent, out.Account.Name, out.Account.Icon = p.ID, pp.Name, pluginIcon(pp.ID)
out.Account.Logins = nil
}
}
exposed := map[string]bool{}
for _, m := range p.Exposed() {
@@ -388,6 +396,7 @@ func providersState() providersJSON {
s.Gateway.Running, s.Gateway.Window = gateway.Serving()
}
s.CodexDaemon = provider.CodexDaemonStale()
s.Plugins = pluginSubs()
return s
}
@@ -407,6 +416,7 @@ func ago(t time.Time) string {
func providerRoutes(mux *http.ServeMux, w Windows) {
importAppsRoutes(mux)
pluginRoutes(mux, w)
traceRoutes(mux)
groupRoutes(mux)
mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) {
+8 -1
View File
@@ -24,6 +24,13 @@ sign-in with pasteCallback shows) in a narrow Chinese
dark window: invalid input remains editable, retry reaches the callback route,
and a pending or accepted submission cannot be submitted twice.
`plugin-signin.test.cjs` checks a provider an OpenCode plugin signs in to:
the add sheet lists it under "From plugins"; its sign-in asks the way, the
method's questions (a pick, then a text the plugin checks), then takes the
code the browser page shows; an API-key way opens the account. Settings →
Plugins lists the plugins, why one didn't load, and adds one. English and
Chinese, Chromium and WebKit, with the API faked.
`panel-fold.test.cjs` expands and collapses on the Agents page with the list
scrolled to its end, in the tray panel (one agent open) and in the window:
"Show {n} more" unrolls the rest under the button, the view going down with
@@ -264,7 +271,7 @@ window, whose title bar has them; shown in the Mac window.
With Node.js and Playwright available:
```sh
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
+146
View File
@@ -0,0 +1,146 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A provider an OpenCode plugin signs in to: the add sheet lists it under
// "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. Settings → Plugins lists the plugins, why one didn't
// load, and adds one. English and Chinese; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const plugin = { id: "fakeco", pid: "fakeco", name: "FakeCo", icon: "generic", spec: "opencode-fakeco-auth", signedIn: false, models: 3,
methods: [{ type: "api", label: "API key" }, { type: "oauth", label: "Browser sign-in" }] };
function server(lang, asked) {
let signedIn = false;
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
const body = () => route.request().postDataJSON();
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/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
if (url.pathname === "/api/plugin-signin/prompt") {
const b = body();
asked.push(["prompt", b]);
const inputs = { ...(b.inputs || {}) };
if (b.key === "team" && !b.value) return json({ error: "Required" });
if (b.key) inputs[b.key] = b.value;
if (b.method === 1 && !inputs.where) return json({ prompt: { type: "select", key: "where", message: "Where do you work?", options: [{ label: "Home", value: "home" }, { label: "Work", value: "work" }] }, inputs });
if (b.method === 1 && inputs.where === "work" && !inputs.team) return json({ prompt: { type: "text", key: "team", message: "Your team", placeholder: "blue" }, inputs });
return json({ prompt: null, inputs });
}
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" }); }
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 }); }
if (url.pathname === "/api/signin/p1") 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/plugins") return json({ bun: true, bunVersion: "1.3.0", plugins: [
{ spec: "opencode-fakeco-auth", providers: ["FakeCo"] },
{ spec: "opencode-broken", error: "Cannot find module 'x'", providers: [] },
] });
if (url.pathname === "/api/plugins/add") { asked.push(["add", body()]); return json({ bun: true, plugins: [{ spec: "opencode-fakeco-auth", providers: ["FakeCo"] }, { spec: body().spec, providers: ["Other"] }] }); }
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const L = {
en: { 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",
plugins: "Plugins", failed: /Didn't load: Cannot find module/, signs: "Signs in to FakeCo", add: "Add" },
zh: { section: "来自插件", how: "用哪种方式登录 FakeCo?", next: "下一步", code: "验证码", finish: "完成登录", key: "FakeCo API Key", signIn: "登录",
plugins: "插件", failed: /没有加载成功:Cannot find module/, signs: "可登录 FakeCo", add: "添加" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": a plugin's provider", async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh"]) {
await t.test(lang, async () => {
const w = L[lang];
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
page.setDefaultTimeout(5000);
const errors = [], asked = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, asked));
await page.goto("http://magpie.test/?view=providers");
await page.locator("#addProvider").click();
const sheet = page.locator("#addSheet");
await sheet.locator(".kind b", { hasText: w.section }).waitFor();
const row = sheet.locator('.tile[data-pick="FakeCo"]');
assert.match(await row.getAttribute("title"), /opencode-fakeco-auth/);
// the browser way: a pick, a text the plugin checks, then the code
await row.click();
const box = sheet.locator(".signing");
await box.locator(".n", { hasText: w.how }).waitFor();
if (process.env.ARTIFACT_DIR) await sheet.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-method-${engine}-${lang}.png`) });
assert.equal(asked.length, 0, "nothing asked before the way is picked");
await box.locator("button", { hasText: "Browser sign-in" }).click();
await box.locator(".n", { hasText: "Where do you work?" }).waitFor();
await box.locator("button", { hasText: "Work" }).click();
const team = box.getByRole("textbox", { name: "Your team" });
await team.waitFor();
if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-prompt-${engine}-${lang}.png`) });
await box.locator("button", { hasText: w.next }).click();
await box.locator(".why", { hasText: "Required" }).waitFor();
await team.fill("blue");
await team.press("Enter");
await box.locator(".s", { hasText: "Paste the code FakeCo shows." }).waitFor();
const signin = asked.find(([k]) => k === "signin")[1];
assert.deepEqual(signin, { provider: "fakeco", method: 1, inputs: { where: "work", team: "blue" } });
if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-code-${engine}-${lang}.png`) });
const code = box.getByRole("textbox", { name: w.code });
await code.fill("abc123");
await box.locator("button", { hasText: w.finish }).click();
for (let i = 0; i < 50 && !asked.some(([k]) => k === "code"); i++) await page.waitForTimeout(50);
assert.deepEqual(asked.find(([k]) => k === "code")[1], { url: "abc123" });
// the key way: the account opens once it's in
await box.locator("button.text:not(.primary)").last().click();
await row.click();
await box.locator("button", { hasText: "API key" }).click();
const key = box.getByLabel(w.key);
await key.waitFor();
assert.equal(await key.getAttribute("type"), "password");
await key.fill("k1");
await box.locator("button", { hasText: w.signIn }).click();
await page.waitForFunction(() => editing === "fakeco");
assert.deepEqual(asked.filter(([k]) => k === "signin").pop()[1], { provider: "fakeco", method: 0, inputs: {}, key: "k1" });
// Settings → Plugins
await page.goto("http://magpie.test/?view=settings");
const list = page.locator("#pluginsList");
await list.locator(".sub", { hasText: w.signs }).waitFor();
await list.locator(".sub.bad", { hasText: w.failed }).waitFor();
await list.getByRole("textbox").fill("opencode-other-auth");
await list.locator("button", { hasText: new RegExp("^" + w.add + "$") }).click();
await list.locator(".name", { hasText: "opencode-other-auth" }).waitFor();
assert.deepEqual(asked.find(([k]) => k === "add")[1], { spec: "opencode-other-auth" });
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await list.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugins-${engine}-${lang}.png`) });
}
assert.deepEqual(errors, []);
});
}
});
}
+189
View File
@@ -0,0 +1,189 @@
package plugin
import (
"archive/zip"
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/yetone/magpie/internal/catalog"
)
// BunVersion is the Bun magpie downloads to run plugins with, the first
// time one is needed.
const BunVersion = "1.3.14"
// bunRelease is where Bun's releases are; a var for tests.
var bunRelease = "https://github.com/oven-sh/bun/releases/download"
var bunMu sync.Mutex
// bunTarget is the name of Bun's build for this machine: bun-darwin-aarch64
// and the like. x64 Linux and Windows get the baseline build, which runs
// on CPUs without AVX2 as well.
func bunTarget() (string, error) {
arch := map[string]string{"arm64": "aarch64", "amd64": "x64"}[runtime.GOARCH]
if arch == "" {
return "", fmt.Errorf("Bun has no build for %s/%s", runtime.GOOS, runtime.GOARCH)
}
switch runtime.GOOS {
case "darwin", "linux":
case "windows":
if arch != "x64" {
return "", fmt.Errorf("Bun has no build for windows/%s", runtime.GOARCH)
}
default:
return "", fmt.Errorf("Bun has no build for %s", runtime.GOOS)
}
t := "bun-" + runtime.GOOS + "-" + arch
if arch == "x64" && runtime.GOOS != "darwin" {
t += "-baseline"
}
return t, nil
}
// bunDir is where the downloaded Bun is kept.
func bunDir() string {
return filepath.Join(filepath.Dir(catalog.CachePath()), "bun", BunVersion)
}
func bunExe() string {
if runtime.GOOS == "windows" {
return "bun.exe"
}
return "bun"
}
// Bun is the bun to run plugins with: $MAGPIE_BUN when set, else the one
// magpie downloaded, downloading it now when there is none yet.
func Bun(ctx context.Context) (string, error) {
if b := os.Getenv("MAGPIE_BUN"); b != "" {
return b, nil
}
bunMu.Lock()
defer bunMu.Unlock()
exe := filepath.Join(bunDir(), bunExe())
if _, err := os.Stat(exe); err == nil {
return exe, nil
}
if err := downloadBun(ctx, exe); err != nil {
return "", fmt.Errorf("downloading Bun %s to run plugins: %w", BunVersion, err)
}
return exe, nil
}
// HasBun is whether a Bun is at hand without a download.
func HasBun() bool {
if os.Getenv("MAGPIE_BUN") != "" {
return true
}
_, err := os.Stat(filepath.Join(bunDir(), bunExe()))
return err == nil
}
func downloadBun(ctx context.Context, exe string) error {
target, err := bunTarget()
if err != nil {
return err
}
base := bunRelease + "/bun-v" + BunVersion + "/"
sums, err := getURL(ctx, base+"SHASUMS256.txt", 1<<20)
if err != nil {
return err
}
want := ""
sc := bufio.NewScanner(strings.NewReader(string(sums)))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) == 2 && f[1] == target+".zip" {
want = f[0]
}
}
if want == "" {
return fmt.Errorf("%s.zip isn't in the release's checksums", target)
}
z, err := getURL(ctx, base+target+".zip", 200<<20)
if err != nil {
return err
}
sum := sha256.Sum256(z)
if hex.EncodeToString(sum[:]) != want {
return errors.New("the download's checksum doesn't match the release's")
}
zr, err := zip.NewReader(bytes.NewReader(z), int64(len(z)))
if err != nil {
return err
}
for _, f := range zr.File {
if filepath.Base(f.Name) != bunExe() || f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil {
return err
}
tmp := exe + ".part"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Rename(tmp, exe)
}
return fmt.Errorf("%s.zip has no %s", target, bunExe())
}
func getURL(ctx context.Context, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", url, res.Status)
}
b, err := io.ReadAll(io.LimitReader(res.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(b)) > limit {
return nil, fmt.Errorf("%s: too large", url)
}
return b, nil
}
// bunCommand runs bun with args in dir, the environment's proxy settings
// passed on.
var bunCommand = func(ctx context.Context, bun, dir string, args ...string) *exec.Cmd {
cmd := command(ctx, bun, args...)
cmd.Dir = dir
cmd.Env = append(env(), "BUN_INSTALL_CACHE_DIR="+filepath.Join(filepath.Dir(catalog.CachePath()), "bun", "install-cache"))
return cmd
}
+498
View File
@@ -0,0 +1,498 @@
package plugin
import (
"bufio"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/settings"
)
//go:embed host.js
var hostJS []byte
// message is a line the host writes.
type message struct {
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
Event string `json:"event"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Data string `json:"data"`
Provider string `json:"provider"`
Level string `json:"level"`
Message string `json:"message"`
Title string `json:"title"`
Variant string `json:"variant"`
}
type call struct {
done chan message // the answer
events chan message // a fetch's head and chunks, before its answer
gone chan struct{} // closed when no one reads events any more
closed atomic.Bool // done has been answered
}
// host is one Bun process running host.js.
type host struct {
cmd *exec.Cmd
in io.WriteCloser
wmu sync.Mutex
mu sync.Mutex
calls map[int64]*call
next int64
dead chan struct{}
err error
loaded []Loaded
}
// Loaded is how a plugin fared when the host loaded it.
type Loaded struct {
Spec string `json:"spec"`
Error string `json:"error,omitempty"`
}
var (
hostMu sync.Mutex
current *host
// generation counts the hosts started, so a caller can tell a
// restart happened (the provider cache is then stale)
generation atomic.Int64
// onChange are told a sign-in or the plugins changed.
onChange []func()
onChangeMu sync.Mutex
)
// OnChange registers f to be told when a plugin sign-in or the plugins
// change (a sign-in saved or refreshed, a plugin added).
func OnChange(f func()) {
onChangeMu.Lock()
onChange = append(onChange, f)
onChangeMu.Unlock()
}
func changed() {
forgetProviders()
onChangeMu.Lock()
fs := append([]func(){}, onChange...)
onChangeMu.Unlock()
for _, f := range fs {
go f()
}
}
// Restart stops the host, so the next call starts one with the plugins
// as they are now.
func Restart() {
hostMu.Lock()
h := current
current = nil
hostMu.Unlock()
if h != nil {
h.stop()
}
changed()
}
// Running is whether a host is up.
func Running() bool {
hostMu.Lock()
defer hostMu.Unlock()
return current != nil && current.alive()
}
func (h *host) alive() bool {
select {
case <-h.dead:
return false
default:
return true
}
}
func (h *host) stop() {
h.in.Close()
select {
case <-h.dead:
case <-time.After(2 * time.Second):
if h.cmd.Process != nil {
h.cmd.Process.Kill()
}
}
}
// hostFile is host.js written where Bun can run it.
func hostFile() (string, error) {
sum := sha256.Sum256(hostJS)
dir := filepath.Join(filepath.Dir(catalog.CachePath()), "plugin-host")
p := filepath.Join(dir, "host-"+hex.EncodeToString(sum[:6])+".js")
if b, err := os.ReadFile(p); err == nil && string(b) == string(hostJS) {
return p, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, hostJS, 0o644); err != nil {
return "", err
}
return p, os.Rename(tmp, p)
}
// get is the running host, started (Bun downloaded, the plugins loaded)
// when there is none.
func get(ctx context.Context) (*host, error) {
hostMu.Lock()
defer hostMu.Unlock()
if current != nil && current.alive() {
return current, nil
}
h, err := start(ctx)
if err != nil {
return nil, err
}
current = h
return h, nil
}
func start(ctx context.Context) (*host, error) {
bun, err := Bun(ctx)
if err != nil {
return nil, err
}
js, err := hostFile()
if err != nil {
return nil, err
}
if err := os.MkdirAll(settings.Dir(), 0o700); err != nil {
return nil, err
}
// the host outlives the request that started it
cmd := bunCommand(context.Background(), bun, settings.Dir(), "run", js)
in, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
h := &host{cmd: cmd, in: in, calls: map[int64]*call{}, dead: make(chan struct{})}
go func() {
sc := bufio.NewScanner(stderr)
sc.Buffer(make([]byte, 64<<10), 4<<20)
for sc.Scan() {
log.Printf("plugin: %s", sc.Text())
}
}()
go h.read(out)
generation.Add(1)
l := Load()
type item struct {
Spec string `json:"spec"`
Target string `json:"target"`
Options map[string]any `json:"options,omitempty"`
}
var items []item
for _, e := range l.Plugins {
if !e.Off {
items = append(items, item{e.Spec, Target(e.Spec), e.Options})
}
}
var res struct {
Plugins []Loaded `json:"plugins"`
}
ictx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
err = h.call(ictx, "init", map[string]any{
"authPath": AuthPath(),
"modelsDevPath": catalog.Source(),
"directory": settings.Dir(),
"config": l.Config,
"plugins": items,
}, &res)
if err != nil {
h.stop()
return nil, fmt.Errorf("starting plugins: %w", err)
}
h.loaded = res.Plugins
for _, p := range res.Plugins {
if p.Error != "" {
log.Printf("plugin %s didn't load: %s", p.Spec, p.Error)
}
}
return h, nil
}
func (h *host) read(out io.Reader) {
r := bufio.NewReaderSize(out, 1<<20)
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
var m message
if json.Unmarshal(line, &m) == nil {
h.dispatch(m)
}
}
if err != nil {
break
}
}
err := h.cmd.Wait()
h.mu.Lock()
if err == nil {
err = errors.New("the plugin host quit")
}
h.err = fmt.Errorf("the plugin host quit: %v", err)
calls := h.calls
h.calls = map[int64]*call{}
h.mu.Unlock()
close(h.dead)
for _, c := range calls {
if c.closed.CompareAndSwap(false, true) {
c.done <- message{Error: &struct {
Message string `json:"message"`
}{h.err.Error()}}
}
}
}
func (h *host) dispatch(m message) {
if m.ID == 0 {
switch m.Event {
case "auth":
changed()
case "toast":
log.Printf("plugin: %s %s", m.Title, m.Message)
case "log":
log.Printf("plugin [%s]: %s", m.Level, m.Message)
}
return
}
h.mu.Lock()
c := h.calls[m.ID]
if m.Event == "" {
delete(h.calls, m.ID)
}
h.mu.Unlock()
if c == nil {
return
}
if m.Event != "" {
if c.events != nil {
select {
case c.events <- m:
case <-c.gone:
}
}
return
}
if c.closed.CompareAndSwap(false, true) {
c.done <- m
}
}
func (h *host) send(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
h.wmu.Lock()
defer h.wmu.Unlock()
_, err = h.in.Write(append(b, '\n'))
return err
}
func (h *host) begin(events bool) (int64, *call) {
c := &call{done: make(chan message, 1), gone: make(chan struct{})}
if events {
c.events = make(chan message, 64)
}
h.mu.Lock()
h.next++
id := h.next
h.calls[id] = c
h.mu.Unlock()
return id, c
}
func (h *host) forget(id int64) {
h.mu.Lock()
delete(h.calls, id)
h.mu.Unlock()
}
// call asks the host method with params and reads its answer into out.
func (h *host) call(ctx context.Context, method string, params, out any) error {
id, c := h.begin(false)
if err := h.send(map[string]any{"id": id, "method": method, "params": params}); err != nil {
h.forget(id)
return err
}
select {
case m := <-c.done:
if m.Error != nil {
return errors.New(m.Error.Message)
}
if out != nil && len(m.Result) > 0 {
return json.Unmarshal(m.Result, out)
}
return nil
case <-ctx.Done():
h.forget(id)
return ctx.Err()
}
}
// Call starts the host if need be and asks it method.
func Call(ctx context.Context, method string, params, out any) error {
h, err := get(ctx)
if err != nil {
return err
}
return h.call(ctx, method, params, out)
}
// Plugins is how each plugin fared when the host loaded it, starting the
// host if need be.
func Plugins(ctx context.Context) ([]Loaded, error) {
h, err := get(ctx)
if err != nil {
return nil, err
}
return h.loaded, nil
}
// FetchRequest is a request a provider's plugin makes.
type FetchRequest struct {
Provider string `json:"provider"`
Model string `json:"model"`
NPM string `json:"npm"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body []byte `json:"-"`
Session string `json:"session,omitempty"`
}
// Fetch sends r through the provider's plugin — its loader's fetch, or
// Bun's own when it gives none — and streams back the reply.
func Fetch(ctx context.Context, r FetchRequest) (*http.Response, error) {
h, err := get(ctx)
if err != nil {
return nil, err
}
id, c := h.begin(true)
params := struct {
FetchRequest
Body string `json:"body,omitempty"`
}{r, base64.StdEncoding.EncodeToString(r.Body)}
if err := h.send(map[string]any{"id": id, "method": "fetch", "params": params}); err != nil {
h.forget(id)
return nil, err
}
abort := func() { _ = h.send(map[string]any{"method": "abort", "params": map[string]any{"id": id}}) }
var head message
select {
case head = <-c.events:
case m := <-c.done:
if m.Error != nil {
return nil, errors.New(m.Error.Message)
}
return nil, errors.New("the plugin answered no response")
case <-ctx.Done():
abort()
h.forget(id)
return nil, ctx.Err()
}
pr, pw := io.Pipe()
res := &http.Response{
StatusCode: head.Status,
Status: fmt.Sprintf("%d %s", head.Status, http.StatusText(head.Status)),
Header: http.Header{},
Body: pr,
Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1,
}
for k, v := range head.Headers {
res.Header.Set(k, v)
}
// the body is decoded already: fetch takes the Content-Encoding off
res.Header.Del("Content-Encoding")
res.Header.Del("Content-Length")
go func() {
defer close(c.gone)
write := func(data string) error {
b, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return err
}
_, err = pw.Write(b)
return err
}
for {
select {
case m := <-c.events:
if err := write(m.Data); err != nil {
// the reader went away, or the chunk is garbled
abort()
h.forget(id)
pw.CloseWithError(err)
return
}
case m := <-c.done:
// the chunks came before the answer: any still queued
// are written first
drain:
for {
select {
case e := <-c.events:
if write(e.Data) != nil {
break drain
}
default:
break drain
}
}
if m.Error != nil {
pw.CloseWithError(errors.New(m.Error.Message))
} else {
pw.Close()
}
return
case <-ctx.Done():
abort()
h.forget(id)
pw.CloseWithError(ctx.Err())
return
}
}
}()
return res, nil
}
+653
View File
@@ -0,0 +1,653 @@
// magpie's plugin host: runs OpenCode (v1) server plugins under Bun and
// answers magpie over stdin/stdout, one JSON message a line.
//
// magpie → host: {id, method, params}; the host answers {id, result} or
// {id, error}. A fetch streams its reply first: {id, event: "head", status,
// headers}, then {id, event: "chunk", data} (base64) as the body comes, then
// {id, result: null}. {method: "abort", params: {id}} cancels one.
// host → magpie, unasked: {event: "log", level, message} and {event:
// "toast", ...} (what a plugin logs or shows), {event: "auth", provider}
// (a sign-in the host saved or refreshed).
//
// The plugins get what OpenCode hands them (PluginInput): a client whose
// auth.set saves to magpie's plugin-auth.json in OpenCode's auth.json shape,
// 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}.
import fs from "node:fs"
import path from "node:path"
import readline from "node:readline"
import { pathToFileURL } from "node:url"
const rpcWrite = process.stdout.write.bind(process.stdout)
const send = (msg) => rpcWrite(JSON.stringify(msg) + "\n")
// A plugin writing to stdout would break the protocol: everything it
// prints goes to stderr, which magpie logs.
const toErr = (...xs) => process.stderr.write(xs.map((x) => (typeof x === "string" ? x : Bun.inspect(x))).join(" ") + "\n")
console.log = console.info = console.debug = console.warn = toErr
process.stdout.write = (chunk, enc, cb) => process.stderr.write(chunk, enc, cb)
let authPath = ""
let modelsDevPath = ""
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 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
// ---- auth.json ---------------------------------------------------------------
function readAuth() {
try {
const v = JSON.parse(fs.readFileSync(authPath, "utf8"))
return v && typeof v === "object" ? v : {}
} catch {
return {}
}
}
function writeAuth(all) {
fs.mkdirSync(path.dirname(authPath), { recursive: true })
const tmp = authPath + ".tmp-" + process.pid
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 })
fs.renameSync(tmp, authPath)
}
function setAuth(id, info) {
const all = readAuth()
all[id] = info
writeAuth(all)
loaders.delete(id)
send({ event: "auth", provider: id })
}
function removeAuth(id) {
const all = readAuth()
delete all[id]
writeAuth(all)
loaders.delete(id)
send({ event: "auth", provider: id })
}
// ---- the client plugins are given --------------------------------------------
function stub(name) {
return new Proxy(async () => ({ data: undefined }), {
get(_, key) {
if (key === "then") return undefined
return stub(name + "." + String(key))
},
apply() {
return Promise.resolve({ data: undefined })
},
})
}
function makeClient() {
const known = {
auth: {
// OpenCode's SDK: auth.set({path: {id}, body})
set: async (opts) => {
const id = opts?.path?.id ?? opts?.id ?? opts?.providerID
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)
}
return { data: true }
},
remove: async (opts) => {
const id = opts?.path?.id ?? opts?.id
if (typeof id === "string") removeAuth(id)
return { data: true }
},
},
tui: {
showToast: async (opts) => {
const b = opts?.body ?? opts ?? {}
send({ event: "toast", title: b.title ?? "", message: String(b.message ?? ""), variant: b.variant ?? "info" })
return { data: true }
},
},
app: {
log: async (opts) => {
const b = opts?.body ?? opts ?? {}
send({ event: "log", level: b.level ?? "info", message: `[${b.service ?? "plugin"}] ${b.message ?? ""}` })
return { data: true }
},
},
config: {
get: async () => ({ data: config }),
},
}
const wrap = (obj, name) =>
new Proxy(obj, {
get(target, key) {
if (key in target) {
const v = target[key]
return v && typeof v === "object" ? wrap(v, name + "." + String(key)) : v
}
if (key === "then") return undefined
return stub(name + "." + String(key))
},
})
return wrap(known, "client")
}
// ---- loading plugins ---------------------------------------------------------
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
function readJSON(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
return undefined
}
}
// entries are the files a plugin's server side may load from, as
// OpenCode looks for them: the package's exports["./server"], its main,
// exports["."], else an index file; a path to a file is that file. A
// package whose ./server is written for OpenCode's next plugin API
// (Plugin.define, opencode-gemini-auth 2) keeps its v1 plugin at main.
function entries(target) {
const stat = fs.statSync(target, { throwIfNoEntry: false })
if (!stat) throw new Error(`no such plugin: ${target}`)
if (!stat.isDirectory()) return [target]
const out = []
const add = (v) => typeof v === "string" && v.trim() && out.push(path.resolve(target, v.trim()))
const pick = (x) => (typeof x === "string" ? x : x?.import ?? x?.default)
const pkg = readJSON(path.join(target, "package.json"))
if (pkg) {
const ex = pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports) ? pkg.exports : undefined
if (ex) add(pick(ex["./server"]))
add(pkg.main)
if (ex) add(pick(ex["."]))
if (typeof pkg.exports === "string") add(pkg.exports)
}
for (const f of INDEX_FILES) {
const p = path.join(target, f)
if (fs.existsSync(p)) out.push(p)
}
if (out.length === 0) throw new Error(`plugin ${target} has no entry (package.json main, exports or index file)`)
return [...new Set(out)]
}
// servers are the plugin functions a module exports: default {id?,
// server} (v1), else every function it exports, or {server} objects
// (legacy), each once.
function servers(mod) {
const d = mod.default
if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) {
return typeof d.server === "function" ? [d.server] : []
}
const seen = new Set()
const out = []
for (const v of Object.values(mod)) {
if (seen.has(v)) continue
seen.add(v)
if (typeof v === "function") out.push(v)
else if (v && typeof v === "object" && typeof v.server === "function") out.push(v.server)
}
return out
}
async function loadPlugins(list) {
const input = {
client: makeClient(),
project: { id: "magpie", worktree: directory, vcs: undefined, time: { created: Date.now() } },
directory,
worktree: directory,
experimental_workspace: { register() {} },
serverUrl: new URL("http://127.0.0.1:4096"),
$: Bun.$,
}
for (const p of list) {
try {
let fns = []
for (const file of entries(p.target)) {
fns = servers(await import(pathToFileURL(file).href))
if (fns.length) break
}
if (fns.length === 0) throw new Error("exports no OpenCode plugin function")
for (const fn of fns) hooks.push({ spec: p.spec, hooks: (await fn(input, p.options)) ?? {} })
loaded.push({ spec: p.spec })
} catch (e) {
loaded.push({ spec: p.spec, error: String(e?.stack ?? e) })
}
}
config = { provider: {}, ...structuredClone(userConfig) }
for (const h of hooks) {
if (typeof h.hooks.config !== "function") continue
try {
await h.hooks.config(config)
} catch (e) {
send({ event: "log", level: "error", message: `${h.spec}: config hook: ${e?.message ?? e}` })
}
}
}
// auths are the auth hooks by provider, the last plugin to name one
// winning, as in OpenCode.
function auths() {
const m = new Map()
for (const h of hooks) if (h.hooks.auth?.provider) m.set(h.hooks.auth.provider, { spec: h.spec, auth: h.hooks.auth })
return m
}
function authOf(provider) {
const a = auths().get(provider)
if (!a) throw new Error(`no plugin signs in to ${provider}`)
return a.auth
}
// ---- providers and models ----------------------------------------------------
let modelsDev
function mdev() {
if (modelsDev === undefined) modelsDev = (modelsDevPath && readJSON(modelsDevPath)) || {}
return modelsDev
}
function cost(c) {
return {
input: c?.input ?? 0,
output: c?.output ?? 0,
cache: { read: c?.cache_read ?? c?.cache?.read ?? 0, write: c?.cache_write ?? c?.cache?.write ?? 0 },
}
}
// fromModelsDev is a models.dev model as OpenCode's provider list has it.
function fromModelsDev(p, m) {
return {
id: m.id,
providerID: p.id,
name: m.name ?? m.id,
family: m.family,
api: { id: m.id, url: m.provider?.api ?? p.api ?? "", npm: m.provider?.npm ?? p.npm ?? "@ai-sdk/openai-compatible" },
status: m.status ?? "active",
headers: {},
options: {},
cost: cost(m.cost),
limit: { context: m.limit?.context ?? 0, input: m.limit?.input, output: m.limit?.output ?? 0 },
capabilities: {
temperature: m.temperature ?? false,
reasoning: m.reasoning ?? false,
attachment: m.attachment ?? false,
toolcall: m.tool_call ?? true,
input: {
text: true,
image: (m.modalities?.input ?? []).includes("image"),
audio: (m.modalities?.input ?? []).includes("audio"),
video: (m.modalities?.input ?? []).includes("video"),
pdf: (m.modalities?.input ?? []).includes("pdf"),
},
output: { text: true, image: false, audio: false, video: false, pdf: false },
interleaved: m.interleaved ?? false,
},
release_date: m.release_date ?? "",
variants: {},
}
}
// 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) {
const md = mdev()[id]
const cfg = config.provider?.[id]
const out = {
id,
name: cfg?.name ?? md?.name ?? id,
source: md ? "api" : "config",
env: cfg?.env ?? md?.env ?? [],
options: { ...(cfg?.options ?? {}) },
npm: cfg?.npm ?? md?.npm,
api: cfg?.api ?? md?.api,
models: {},
}
if (md) for (const m of Object.values(md.models ?? {})) out.models[m.id] = fromModelsDev(md, m)
for (const [key, m] of Object.entries(cfg?.models ?? {})) {
const was = out.models[m.id ?? key]
const npm = m.provider?.npm ?? cfg.npm ?? was?.api.npm ?? md?.npm ?? "@ai-sdk/openai-compatible"
out.models[key] = {
...(was ?? {}),
id: key,
providerID: id,
name: m.name ?? was?.name ?? key,
api: { id: m.id ?? was?.api.id ?? key, url: m.provider?.api ?? cfg.api ?? was?.api.url ?? md?.api ?? "", npm },
cost: m.cost ? cost(m.cost) : was?.cost ?? cost(),
limit: { ...(was?.limit ?? { context: 0, output: 0 }), ...(m.limit ?? {}) },
options: { ...(was?.options ?? {}), ...(m.options ?? {}) },
headers: { ...(was?.headers ?? {}), ...(m.headers ?? {}) },
capabilities: {
...(was?.capabilities ?? { temperature: true, toolcall: true, input: { text: true }, output: { text: true } }),
...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
...(m.attachment !== undefined ? { attachment: m.attachment } : {}),
...(m.tool_call !== undefined ? { toolcall: m.tool_call } : {}),
...(m.modalities?.input ? { input: Object.fromEntries(["text", "image", "audio", "video", "pdf"].map((k) => [k, m.modalities.input.includes(k)])) } : {}),
},
variants: m.variants ?? was?.variants ?? {},
}
}
for (const h of hooks) {
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] })
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}` })
}
}
for (const [k, m] of Object.entries(cfg?.models ?? {})) if (m?.disabled) delete out.models[k]
return out
}
function methods(auth) {
return (auth.methods ?? []).map((m) => ({ type: m.type, label: m.label }))
}
async function providers() {
const stored = readAuth()
const out = []
for (const [id, a] of auths()) {
const p = await info(id)
out.push({
id,
spec: a.spec,
name: p.name,
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 ?? "",
models: Object.values(p.models)
.filter((m) => m.status !== "deprecated")
.map((m) => ({
id: m.id,
name: m.name,
npm: m.api?.npm ?? p.npm ?? "",
url: m.api?.url ?? "",
apiId: m.api?.id ?? m.id,
context: m.limit?.context ?? 0,
input: m.limit?.input ?? 0,
output: m.limit?.output ?? 0,
reasoning: !!m.capabilities?.reasoning,
image: !!m.capabilities?.input?.image,
released: m.release_date ?? "",
cost: m.cost,
variants: Object.keys(m.variants ?? {}),
})),
})
}
return out
}
// ---- signing in --------------------------------------------------------------
function applies(prompt, inputs) {
if (prompt.when) {
const v = inputs[prompt.when.key]
if (v === undefined) return false
const eq = v === prompt.when.value
if (prompt.when.op === "eq" ? !eq : eq) return false
}
if (typeof prompt.condition === "function" && !prompt.condition(inputs)) return false
return true
}
// nextPrompt is the method's next question for inputs so far, as
// OpenCode's CLI asks them: in order, those whose when/condition hold.
function nextPrompt(provider, index, inputs) {
const m = authOf(provider).methods[index]
if (!m) throw new Error(`no sign-in method ${index} for ${provider}`)
for (const p of m.prompts ?? []) {
if (p.key in inputs) continue
if (!applies(p, inputs)) continue
return {
type: p.type,
key: p.key,
message: p.message,
placeholder: p.placeholder ?? "",
options: p.type === "select" ? p.options.map((o) => ({ label: o.label, value: o.value, hint: o.hint ?? "" })) : undefined,
}
}
return null
}
function validate(provider, index, key, value) {
const p = (authOf(provider).methods[index]?.prompts ?? []).find((x) => x.key === key)
if (!p || typeof p.validate !== "function") return null
return p.validate(value) ?? null
}
// save keeps a successful sign-in as OpenCode's CLI does.
function save(provider, result, inputs, apiKey) {
const id = result.provider ?? provider
if ("refresh" in result) {
const { type: _t, provider: _p, refresh, access, expires, ...extra } = result
setAuth(id, { 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 } : {}) })
}
return id
}
let nextSession = 1
async function authorize({ provider, method, inputs }) {
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 session = String(nextSession++)
sessions.set(session, { provider, a, inputs })
return { session, url: a.url ?? "", instructions: a.instructions ?? "", method: a.method }
}
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()
if (!r || r.type !== "success") return { ok: false }
return { ok: true, provider: save(s.provider, r, undefined) }
}
async function apiKey({ provider, method, inputs, key }) {
const m = authOf(provider).methods[method]
if (!m || m.type !== "api") throw new Error("not an API key method")
if (typeof m.authorize !== "function") {
const md = inputs && Object.keys(inputs).length ? { metadata: inputs } : {}
setAuth(provider, { type: "api", key, ...md })
return { ok: true, provider }
}
const r = await m.authorize(inputs ?? {})
if (!r || r.type !== "success") return { ok: false }
return { ok: true, provider: save(provider, 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)
const a = auths().get(provider)?.auth
const stored = readAuth()[provider]
let opts = {}
if (a?.loader && stored) {
const p = await info(provider)
opts = (await a.loader(async () => readAuth()[provider], JSON.parse(JSON.stringify(p)))) ?? {}
}
if (!opts.apiKey && stored?.type === "api") opts = { ...opts, apiKey: stored.key }
loaders.set(provider, opts)
return opts
}
async function load({ provider }) {
const o = await options(provider)
return {
baseURL: typeof o.baseURL === "string" ? o.baseURL : "",
apiKey: typeof o.apiKey === "string" ? o.apiKey : "",
headers: o.headers && typeof o.headers === "object" ? o.headers : {},
fetch: typeof o.fetch === "function",
}
}
// sdkHeaders are what the AI SDK package the model is on sends of the key.
function sdkHeaders(npm, key) {
if (!key) return {}
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { "x-api-key": key }
if (npm === "@ai-sdk/google") return { "x-goog-api-key": key }
return { authorization: `Bearer ${key}` }
}
async function doFetch(id, { provider, model, npm, url, method, headers, body, session }) {
const ctl = new AbortController()
inflight.set(id, ctl)
try {
const o = await options(provider)
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 m = p.models[model] ?? { id: model, providerID: provider, api: { id: model, npm } }
for (const x of hooks) {
const fn = x.hooks["chat.headers"]
if (typeof fn !== "function") continue
const out = { headers: {} }
try {
await fn(
{
sessionID: session ?? "",
agent: "build",
model: m,
provider: { source: "custom", info: p, options: o },
message: { id: "", sessionID: session ?? "", role: "user", time: { created: Date.now() }, agent: "build", model: { providerID: provider, modelID: model } },
},
out,
)
} catch (e) {
send({ event: "log", level: "error", message: `${x.spec}: chat.headers: ${e?.message ?? e}` })
}
for (const [k, v] of Object.entries(out.headers)) h.set(k, String(v))
}
const f = typeof o.fetch === "function" ? o.fetch : fetch
const res = await f(url, {
method: method ?? "POST",
headers: h,
body: body ? Buffer.from(body, "base64") : undefined,
signal: ctl.signal,
})
const rh = {}
res.headers.forEach((v, k) => (rh[k] = v))
send({ id, event: "head", status: res.status, headers: rh })
if (res.body) {
for await (const chunk of res.body) {
send({ id, event: "chunk", data: Buffer.from(chunk).toString("base64") })
}
}
send({ id, result: null })
} catch (e) {
send({ id, error: { message: String(e?.message ?? e) } })
} finally {
inflight.delete(id)
}
}
// ---- the loop ----------------------------------------------------------------
const handlers = {
async init(p) {
authPath = p.authPath
modelsDevPath = p.modelsDevPath ?? ""
directory = p.directory ?? directory
userConfig = p.config ?? {}
await loadPlugins(p.plugins ?? [])
return { plugins: loaded }
},
providers: () => providers(),
prompt: (p) => ({ prompt: nextPrompt(p.provider, p.method, p.inputs ?? {}) }),
validate: (p) => ({ error: validate(p.provider, p.method, p.key, p.value) }),
authorize,
callback,
apiKey,
load,
signOut(p) {
removeAuth(p.provider)
return null
},
reload(p) {
loaders.delete(p.provider)
return null
},
}
// Everything waits for init; a request is answered even when magpie has
// closed stdin behind it, the host leaving once nothing is pending.
let ready
let pending = 0
let closed = false
const done = () => {
if (--pending === 0 && closed) process.exit(0)
}
const rl = readline.createInterface({ input: process.stdin, terminal: false })
rl.on("line", (line) => {
if (!line.trim()) return
let msg
try {
msg = JSON.parse(line)
} catch {
return
}
if (msg.method === "abort") {
inflight.get(msg.params?.id)?.abort()
return
}
pending++
if (msg.method === "init") {
ready = handlers.init(msg.params ?? {})
ready.then(
(result) => send({ id: msg.id, result }),
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
).finally(done)
return
}
const wait = ready ?? Promise.reject(new Error("the host has not been initialised"))
if (msg.method === "fetch") {
wait.then(() => doFetch(msg.id, msg.params ?? {}), (e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } })).finally(done)
return
}
const fn = handlers[msg.method]
if (!fn) {
send({ id: msg.id, error: { message: `no method ${msg.method}` } })
done()
return
}
wait
.then(() => fn(msg.params ?? {}))
.then(
(result) => send({ id: msg.id, result: result ?? null }),
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
)
.finally(done)
})
rl.on("close", () => {
closed = true
if (pending === 0) process.exit(0)
})
+168
View File
@@ -0,0 +1,168 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// sandbox gives the test its own magpie folders and the Bun on PATH; a
// machine without Bun skips it.
func sandbox(t *testing.T) {
t.Helper()
bun, err := exec.LookPath("bun")
if err != nil {
t.Skip("no bun on PATH")
}
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "cache"))
t.Setenv("MAGPIE_BUN", bun)
t.Cleanup(Settle)
}
func TestFakePlugin(t *testing.T) {
sandbox(t)
var seen []http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Clone())
b, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
fl := w.(http.Flusher)
fmt.Fprintf(w, "data: %s %s\n\n", r.URL.Path, b)
fl.Flush()
time.Sleep(50 * time.Millisecond)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
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)
}
ls, err := Plugins(ctx)
if err != nil || len(ls) != 1 || ls[0].Error != "" {
t.Fatalf("Plugins = %+v, %v", ls, err)
}
ps, err := Providers(ctx)
if err != nil {
t.Fatal(err)
}
if len(ps) != 1 || ps[0].ID != "fakeco" || ps[0].Name != "FakeCo" || ps[0].SignedIn || len(ps[0].Methods) != 2 {
t.Fatalf("Providers = %+v", ps)
}
npm := map[string]string{}
for _, m := range ps[0].Models {
npm[m.ID] = m.NPM
}
if npm["fake-1"] != "@ai-sdk/openai-compatible" || npm["fake-claude"] != "@ai-sdk/anthropic" || npm["fake-gemini"] != "@ai-sdk/google" {
t.Fatalf("models = %+v", ps[0].Models)
}
// the browser method asks where, then a team only for work
p, err := NextPrompt(ctx, "fakeco", 1, map[string]string{})
if err != nil || p == nil || p.Key != "where" || len(p.Options) != 2 {
t.Fatalf("first prompt = %+v, %v", p, err)
}
if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "home"}); p != nil {
t.Fatalf("home asked %+v", p)
}
if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "work"}); p == nil || p.Key != "team" {
t.Fatalf("work asked %+v", p)
}
if e, _ := Validate(ctx, "fakeco", 1, "team", ""); e != "Required" {
t.Fatalf("Validate = %q", e)
}
in := map[string]string{"where": "work", "team": "blue"}
a, err := Authorize(ctx, "fakeco", 1, in)
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)
}
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" {
t.Fatalf("saved %v", saved)
}
if fi, _ := os.Stat(AuthPath()); fi.Mode().Perm() != 0o600 {
t.Fatalf("plugin-auth.json is %v", fi.Mode().Perm())
}
if !SignedIn("fakeco") {
t.Fatal("not signed in")
}
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)
}
res, err := Fetch(ctx, FetchRequest{
Provider: "fakeco", Model: "fake-1", NPM: "@ai-sdk/openai-compatible",
URL: o.BaseURL + "/chat/completions", Method: "POST",
Headers: map[string]string{"content-type": "application/json"},
Body: []byte(`{"model":"fake-1"}`),
})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || res.StatusCode != 200 || res.Header.Get("Content-Type") != "text/event-stream" {
t.Fatalf("Fetch = %d %v %v", res.StatusCode, res.Header, err)
}
if want := "data: /v1/chat/completions {\"model\":\"fake-1\"}\n\ndata: [DONE]\n\n"; string(body) != want {
t.Fatalf("body = %q", body)
}
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" {
t.Fatalf("headers = %v", h)
}
b, _ = os.ReadFile(AuthPath())
json.Unmarshal(b, &saved)
if saved["fakeco"]["access"] != "fresh" {
t.Fatalf("refresh not saved: %v", saved)
}
// a request that is abandoned is aborted, and the host still answers
cctx, ccancel := context.WithCancel(ctx)
res, err = Fetch(cctx, FetchRequest{Provider: "fakeco", Model: "fake-1", URL: o.BaseURL + "/chat/completions", Method: "POST", Body: []byte(`{}`)})
if err != nil {
t.Fatal(err)
}
ccancel()
io.ReadAll(res.Body)
if _, err := Providers(ctx); err != nil {
t.Fatal(err)
}
if err := SignOut(ctx, "fakeco"); err != nil || SignedIn("fakeco") {
t.Fatalf("SignOut: %v", err)
}
if err := SetOff(abs, true); err != nil {
t.Fatal(err)
}
if ps := Cached(); len(ps) != 0 {
t.Fatalf("a plugin turned off still lists %+v", ps)
}
}
+325
View File
@@ -0,0 +1,325 @@
package plugin
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"github.com/yetone/magpie/internal/settings"
)
// Method is a way a plugin signs in: "oauth" (a browser, then a code
// pasted back or not) or "api" (a key).
type Method struct {
Type string `json:"type"`
Label string `json:"label"`
}
// Model is a model a plugin's provider serves, as OpenCode lists it.
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
NPM string `json:"npm"` // the AI SDK package it is spoken to with
URL string `json:"url"`
APIID string `json:"apiId"`
Context int `json:"context"`
Input int `json:"input"`
Output int `json:"output"`
Reasoning bool `json:"reasoning"`
Image bool `json:"image"`
Released string `json:"released"`
Variants []string `json:"variants"`
Cost *struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
} `json:"cost"`
}
// Provider is a provider a plugin signs in to.
type Provider struct {
ID string `json:"id"` // OpenCode's: google, github-copilot
Spec string `json:"spec"` // the plugin
Name string `json:"name"`
NPM string `json:"npm"`
API string `json:"api"`
Methods []Method `json:"methods"`
SignedIn bool `json:"signedIn"`
AuthType string `json:"authType"`
AccountID string `json:"accountId"`
Models []Model `json:"models"`
}
var (
provMu sync.Mutex
provCache []Provider
provGood bool
)
func providersPath() string { return filepath.Join(settings.Dir(), "plugin-providers.json") }
func forgetProviders() {
provMu.Lock()
provGood = false
provMu.Unlock()
}
// Providers asks the plugins for their providers, starting the host if
// need be, and keeps the answer for Cached.
func Providers(ctx context.Context) ([]Provider, error) {
var ps []Provider
if err := Call(ctx, "providers", nil, &ps); err != nil {
return nil, err
}
provMu.Lock()
provCache, provGood = ps, true
provMu.Unlock()
if b, err := json.Marshal(ps); err == nil {
_ = os.WriteFile(providersPath(), b, 0o600)
}
return ps, nil
}
// refreshing is Cached's refreshes in the background.
var refreshing sync.WaitGroup
// Settle waits for Cached's refreshes to end, then stops the host: for
// tests, whose folders the host runs in go when they end.
func Settle() {
refreshing.Wait()
Restart()
}
// Cached is the plugins' providers as last asked, without starting the
// host: what is known of them when magpie has only just started. A
// provider's sign-in is read afresh from plugin-auth.json.
func Cached() []Provider {
provMu.Lock()
ps := provCache
good := provGood
provMu.Unlock()
if ps == nil {
if b, err := os.ReadFile(providersPath()); err == nil {
_ = json.Unmarshal(b, &ps)
}
}
if len(Load().Plugins) == 0 {
return nil
}
auth := readAuth()
out := make([]Provider, 0, len(ps))
on := map[string]bool{}
for _, e := range Load().Plugins {
if !e.Off {
on[e.Spec] = true
}
}
for _, p := range ps {
if !on[p.Spec] {
continue
}
a, ok := auth[p.ID]
p.SignedIn = ok
p.AuthType = ""
if ok {
p.AuthType = a.Type
if a.AccountID != "" {
p.AccountID = a.AccountID
}
}
out = append(out, p)
}
if !good && len(Load().Plugins) > 0 {
// refreshed in the background: a sign-in or the plugins changed
refreshing.Add(1)
go func() {
defer refreshing.Done()
if Running() || HasBun() {
_, _ = Providers(context.Background())
}
}()
provMu.Lock()
provGood = true // one refresh at a time; a failure is retried on the next change
provMu.Unlock()
}
return out
}
type storedAuth struct {
Type string `json:"type"`
AccountID string `json:"accountId"`
}
func readAuth() map[string]storedAuth {
var m map[string]storedAuth
if b, err := os.ReadFile(AuthPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
// SignedIn is whether a plugin sign-in is kept for provider.
func SignedIn(provider string) bool {
_, ok := readAuth()[provider]
return ok
}
// Prompt is a question a sign-in method asks before it starts.
type Prompt struct {
Type string `json:"type"` // text, select
Key string `json:"key"`
Message string `json:"message"`
Placeholder string `json:"placeholder,omitempty"`
Options []struct {
Label string `json:"label"`
Value string `json:"value"`
Hint string `json:"hint,omitempty"`
} `json:"options,omitempty"`
}
// NextPrompt is the method's next question given the answers so far; nil
// when it has asked them all.
func NextPrompt(ctx context.Context, provider string, method int, inputs map[string]string) (*Prompt, error) {
var r struct {
Prompt *Prompt `json:"prompt"`
}
err := Call(ctx, "prompt", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &r)
return r.Prompt, err
}
// Validate is what the method says is wrong with value as the answer to
// key, "" when nothing is.
func Validate(ctx context.Context, provider string, method int, key, value string) (string, error) {
var r struct {
Error *string `json:"error"`
}
if err := Call(ctx, "validate", map[string]any{"provider": provider, "method": method, "key": key, "value": value}, &r); err != nil {
return "", err
}
if r.Error == nil {
return "", nil
}
return *r.Error, nil
}
// Authorization is an OAuth sign-in begun: the page to open, and whether
// the plugin waits for it itself ("auto") or needs the code the page
// shows pasted back ("code").
type Authorization struct {
Session string `json:"session"`
URL string `json:"url"`
Instructions string `json:"instructions"`
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) {
var a Authorization
err := Call(ctx, "authorize", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &a)
return a, err
}
// 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) {
var r struct {
OK bool `json:"ok"`
Provider string `json:"provider"`
}
if err := Call(ctx, "callback", map[string]any{"session": session, "code": code}, &r); err != nil {
return "", err
}
if !r.OK {
return "", ErrFailed
}
return r.Provider, 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) {
var r struct {
OK bool `json:"ok"`
Provider string `json:"provider"`
}
if err := Call(ctx, "apiKey", map[string]any{"provider": provider, "method": method, "inputs": inputs, "key": key}, &r); err != nil {
return "", err
}
if !r.OK {
return "", ErrFailed
}
return r.Provider, nil
}
// SignOut forgets the provider's sign-in.
func SignOut(ctx context.Context, provider string) error {
if Running() {
return Call(ctx, "signOut", map[string]any{"provider": provider}, nil)
}
var m map[string]json.RawMessage
b, err := os.ReadFile(AuthPath())
if err != nil {
return nil
}
if err := json.Unmarshal(b, &m); err != nil {
return err
}
delete(m, provider)
b, _ = json.MarshalIndent(m, "", " ")
if err := os.WriteFile(AuthPath(), append(b, '\n'), 0o600); err != nil {
return err
}
changed()
return nil
}
// Options is what the provider's auth loader gave: where its requests
// go, the key the AI SDK would send, and whether it carries them itself.
type Options struct {
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
Headers map[string]string `json:"headers"`
Fetch bool `json:"fetch"`
}
var (
optMu sync.Mutex
optCache = map[string]Options{}
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) {
optMu.Lock()
if optGen != generation.Load() {
optCache, optGen = map[string]Options{}, generation.Load()
}
o, ok := optCache[provider]
optMu.Unlock()
if ok {
return o, nil
}
if err := Call(ctx, "load", map[string]any{"provider": provider}, &o); err != nil {
return Options{}, err
}
optMu.Lock()
if optGen == generation.Load() {
optCache[provider] = o
}
optMu.Unlock()
return o, nil
}
func init() {
OnChange(func() {
optMu.Lock()
optCache = map[string]Options{}
optMu.Unlock()
})
}
+270
View File
@@ -0,0 +1,270 @@
// Package plugin runs OpenCode's provider plugins for magpie.
//
// A subscription a vendor may object to being used outside its own app is
// not built into magpie: a plugin signs in to it and makes its requests,
// the way OpenCode's plugins do (opencode-gemini-auth and the like), and
// magpie runs those same plugins. They are npm packages (or local files)
// written against OpenCode's plugin API (@opencode-ai/plugin, v1): their
// auth hook gives the sign-in methods and a loader whose fetch carries the
// requests, their config and provider hooks the models. magpie runs them
// under Bun, downloaded the first time a plugin is added, in one process
// (host.js) it talks to over stdin and stdout.
//
// What magpie keeps: plugins.json, the plugins and the config handed to
// them (OpenCode's, its provider section: a plugin's options); the npm
// packages under plugins/; plugin-auth.json, the sign-ins, in OpenCode's
// auth.json shape (so one can be moved between the two).
package plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"github.com/yetone/magpie/internal/netproxy"
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/settings"
)
// Entry is one plugin the user added.
type Entry struct {
// Spec is how it was added: an npm package (name, name@version) or a
// path to a file or folder.
Spec string `json:"spec"`
// Off is set on a plugin the user turned off.
Off bool `json:"off,omitempty"`
// Options are handed to the plugin as its second argument, as
// OpenCode's ["name", {options}] does.
Options map[string]any `json:"options,omitempty"`
}
// List is plugins.json.
type List struct {
Plugins []Entry `json:"plugins"`
// Config is OpenCode's config the plugins are handed: its provider
// section says what a plugin reads of it
// (provider.google.options.projectId).
Config map[string]any `json:"config,omitempty"`
}
var listMu sync.Mutex
// Dir is the folder magpie installs npm plugins into.
func Dir() string { return filepath.Join(settings.Dir(), "plugins") }
// AuthPath is the plugins' sign-ins, OpenCode's auth.json in shape.
func AuthPath() string { return filepath.Join(settings.Dir(), "plugin-auth.json") }
func listPath() string { return filepath.Join(settings.Dir(), "plugins.json") }
// Load reads plugins.json.
func Load() List {
var l List
if b, err := os.ReadFile(listPath()); err == nil {
_ = json.Unmarshal(b, &l)
}
return l
}
func save(l List) error {
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(settings.Dir(), 0o700); err != nil {
return err
}
return os.WriteFile(listPath(), append(b, '\n'), 0o600)
}
// IsPath is whether spec names a file or folder rather than a package,
// as OpenCode tells them apart.
func IsPath(spec string) bool {
return strings.HasPrefix(spec, "file://") || strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(spec)
}
var pkgName = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`)
// Name is the package spec names, without its version: "@scope/x" of
// "@scope/x@1.2", or the path.
func Name(spec string) string {
if IsPath(spec) {
return spec
}
at := strings.LastIndex(spec, "@")
if at > 0 {
return spec[:at]
}
return spec
}
// Target is where the plugin is to load from: the installed package's
// folder, or the path.
func Target(spec string) string {
if IsPath(spec) {
p := strings.TrimPrefix(spec, "file://")
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
return filepath.Join(Dir(), "node_modules", filepath.FromSlash(Name(spec)))
}
// Add installs a plugin and adds it to the list, in place of one of the
// same package. A package is installed with its scripts left unrun.
func Add(ctx context.Context, spec string) (Entry, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return Entry{}, errors.New("no plugin given")
}
if IsPath(spec) {
t := Target(spec)
if _, err := os.Stat(t); err != nil {
return Entry{}, err
}
spec = t
} else {
if !pkgName.MatchString(Name(spec)) {
return Entry{}, fmt.Errorf("%q isn't an npm package name", spec)
}
if Name(spec) == spec {
spec += "@latest"
}
if err := install(ctx, spec); err != nil {
return Entry{}, err
}
}
listMu.Lock()
defer listMu.Unlock()
l := Load()
e := Entry{Spec: spec}
if i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == Name(spec) }); i >= 0 {
e.Options = l.Plugins[i].Options
l.Plugins[i] = e
} else {
l.Plugins = append(l.Plugins, e)
}
if err := save(l); err != nil {
return Entry{}, err
}
Restart()
return e, nil
}
// Update installs the version of each npm plugin its spec says now
// (latest, for the most part).
func Update(ctx context.Context) error {
var errs []error
for _, e := range Load().Plugins {
if IsPath(e.Spec) {
continue
}
if err := install(ctx, e.Spec); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", e.Spec, err))
}
}
Restart()
return errors.Join(errs...)
}
// Remove takes a plugin off the list (and out of plugins/).
func Remove(ctx context.Context, name string) error {
listMu.Lock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
if i < 0 {
listMu.Unlock()
return fmt.Errorf("no plugin %q", name)
}
e := l.Plugins[i]
l.Plugins = slices.Delete(l.Plugins, i, i+1)
err := save(l)
listMu.Unlock()
if err != nil {
return err
}
if !IsPath(e.Spec) {
if bun, err := Bun(ctx); err == nil {
_ = bunCommand(ctx, bun, Dir(), "remove", "--ignore-scripts", Name(e.Spec)).Run()
}
}
Restart()
return nil
}
// SetOff turns a plugin off or back on.
func SetOff(name string, off bool) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
if i < 0 {
return fmt.Errorf("no plugin %q", name)
}
l.Plugins[i].Off = off
if err := save(l); err != nil {
return err
}
Restart()
return nil
}
// SetConfig sets the OpenCode config the plugins are handed.
func SetConfig(cfg map[string]any) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
l.Config = cfg
if err := save(l); err != nil {
return err
}
Restart()
return nil
}
// install puts an npm package into plugins/ with bun add, its scripts
// left unrun as OpenCode leaves them.
func install(ctx context.Context, spec string) error {
bun, err := Bun(ctx)
if err != nil {
return err
}
if err := os.MkdirAll(Dir(), 0o755); err != nil {
return err
}
pj := filepath.Join(Dir(), "package.json")
if _, err := os.Stat(pj); err != nil {
if err := os.WriteFile(pj, []byte("{\n \"name\": \"magpie-plugins\",\n \"private\": true\n}\n"), 0o644); err != nil {
return err
}
}
out, err := bunCommand(ctx, bun, Dir(), "add", "--ignore-scripts", spec).CombinedOutput()
if err != nil {
return fmt.Errorf("bun add %s: %v: %s", spec, err, lastLines(string(out), 6))
}
return nil
}
func lastLines(s string, n int) string {
ls := strings.Split(strings.TrimSpace(s), "\n")
if len(ls) > n {
ls = ls[len(ls)-n:]
}
return strings.Join(ls, "\n")
}
func command(ctx context.Context, name string, args ...string) *exec.Cmd {
return proc.CommandContext(ctx, name, args...)
}
// env is the plugins' environment: magpie's, with its proxy.
func env() []string { return netproxy.Env(os.Environ()) }
+58
View File
@@ -0,0 +1,58 @@
// A plugin as OpenCode's are written, signing in to a made-up provider
// whose requests go to $FAKE_BASE.
export const FakePlugin = async ({ client }) => ({
config: async (cfg) => {
cfg.provider = cfg.provider ?? {}
cfg.provider.fakeco = {
name: "FakeCo",
npm: "@ai-sdk/openai-compatible",
api: "https://fake.invalid/v1",
models: {
"fake-1": { name: "Fake One", limit: { context: 1000, output: 100 } },
"fake-claude": { name: "Fake Claude", provider: { npm: "@ai-sdk/anthropic" }, limit: { context: 2000, output: 200 } },
"fake-gemini": { name: "Fake Gemini", provider: { npm: "@ai-sdk/google" }, reasoning: true, limit: { context: 3000, output: 300 } },
},
}
},
auth: {
provider: "fakeco",
methods: [
{ type: "api", label: "API key" },
{
type: "oauth",
label: "Browser",
prompts: [
{ type: "select", key: "where", message: "Where?", options: [{ label: "Home", value: "home" }, { label: "Work", value: "work" }] },
{ type: "text", key: "team", message: "Team?", when: { key: "where", op: "eq", value: "work" }, validate: (v) => (v ? undefined : "Required") },
],
authorize: async (inputs) => ({
url: "https://fake.invalid/auth?where=" + inputs.where,
instructions: "Paste the code",
method: "code",
callback: async (code) =>
code === "good"
? { type: "success", refresh: "r-" + (inputs.team ?? "none"), access: "stale", expires: 0, accountId: "me@fake" }
: { type: "failed" },
}),
},
],
loader: async (getAuth, provider) => ({
apiKey: "dummy",
baseURL: process.env.FAKE_BASE,
async fetch(url, init) {
let a = await getAuth()
if (a.type === "oauth" && a.expires < Date.now()) {
a = { ...a, access: "fresh", expires: Date.now() + 3600e3 }
await client.auth.set({ path: { id: "fakeco" }, body: a })
}
const h = new Headers(init.headers)
h.set("authorization", "Bearer " + (a.type === "oauth" ? a.access : a.key))
h.set("x-models", Object.keys(provider.models).sort().join(","))
return fetch(url, { ...init, headers: h })
},
}),
},
"chat.headers": async (input, output) => {
if (input.model.providerID === "fakeco") output.headers["x-plugin-model"] = input.model.id
},
})
+10 -1
View File
@@ -31,6 +31,7 @@ import (
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/proc"
)
@@ -78,6 +79,11 @@ type Account struct {
// explain adds what the user can do about a refusal the account's
// backend answered, "" when there is nothing to add (factory.go).
explain func(status int, body []byte) string
// plugin is set on a plugin's provider (plugins.go), and transport
// carries its requests: the plugin's fetch.
plugin *plugin.Provider
transport func(req *http.Request) (*http.Response, error)
}
// APIs lists the APIs model is served on, as the provider's last model
@@ -85,6 +91,9 @@ type Account struct {
// Claude models on Chat and Anthropic's. nil is not known, and every API
// the provider speaks may be tried.
func (p Provider) APIs(model string) []Protocol {
if p.IsPlugin() {
return p.pluginAPIs(model)
}
ms, _, _ := catalog.Live(p.ID)
for _, m := range ms {
if m.ID == model && len(m.APIs) > 0 {
@@ -829,7 +838,7 @@ func Accounts() []Provider {
out = append(out, p)
}
}
return out
return append(out, pluginAccounts()...)
}
func readJSON(path string, v any) bool {
+317
View File
@@ -0,0 +1,317 @@
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
)
// A plugin's provider (see internal/plugin) is a subscription like the
// built-in ones: signed in to through the plugin, its models the ones the
// plugin lists, its requests made by the plugin's fetch. It speaks what
// OpenCode would speak to each model, by the AI SDK package the model is
// spoken to with.
// pluginBase is where a plugin provider's requests seem to go, for the
// gateway: its transport takes them to where the plugin says instead.
const pluginBase = "plugin://"
// ConversationHeader carries the conversation a request belongs to, to a
// plugin's chat.headers hook as its session; it goes no further.
const ConversationHeader = "X-Magpie-Conversation"
// PluginID is the id magpie gives the provider OpenCode calls id: the same,
// unless a preset or a built-in subscription has it (google, openai,
// anthropic), when it is id-plugin.
func PluginID(id string) string {
if slices.Contains(accountIDs, id) || Preset(id) != nil || id == "magpie" {
return id + "-plugin"
}
return id
}
// IsPlugin is whether the provider is a plugin's.
func (p Provider) IsPlugin() bool { return p.Account != nil && p.Account.plugin != nil }
// PluginOf is the plugin provider magpie's provider id is, when it is one.
func PluginOf(id string) (plugin.Provider, bool) {
for _, pp := range plugin.Cached() {
if PluginID(pp.ID) == id {
return pp, true
}
}
return plugin.Provider{}, false
}
// pluginAccounts are the plugins' providers signed in to.
func pluginAccounts() []Provider {
var out []Provider
for _, pp := range plugin.Cached() {
if pp.SignedIn {
out = append(out, pluginProvider(pp))
}
}
return out
}
// pluginProtocol is the API model is spoken to on, as OpenCode's AI SDK
// package for it speaks: Anthropic's, OpenAI's Responses (and Copilot's
// for GPT-5 and after), Gemini's (as Code Assist's inner request), else
// chat completions.
func pluginProtocol(provider string, m plugin.Model) Protocol {
switch m.NPM {
case "@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic":
return Anthropic
case "@ai-sdk/openai", "@ai-sdk/azure":
return Responses
case "@ai-sdk/google":
return CodeAssist
case "@ai-sdk/github-copilot":
return copilotAPI(modelAPIID(m))
}
if strings.HasPrefix(provider, "github-copilot") {
return copilotAPI(modelAPIID(m))
}
return Chat
}
var gptN = regexp.MustCompile(`^gpt-(\d+)`)
// copilotAPI is OpenCode's shouldUseCopilotResponsesApi: GPT-5 and after,
// but for gpt-5-mini, on Responses.
func copilotAPI(model string) Protocol {
if m := gptN.FindStringSubmatch(model); m != nil {
if n, _ := strconv.Atoi(m[1]); n >= 5 && !strings.HasPrefix(model, "gpt-5-mini") {
return Responses
}
}
return Chat
}
// npmBase is where the AI SDK package sends a model by default.
var npmBase = map[string]string{
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
"@ai-sdk/github-copilot": "https://api.githubcopilot.com",
"@ai-sdk/xai": "https://api.x.ai/v1",
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
"@ai-sdk/deepseek": "https://api.deepseek.com/v1",
}
func pluginModel(pp plugin.Provider, id string) (plugin.Model, bool) {
for _, m := range pp.Models {
if m.ID == id {
return m, true
}
}
return plugin.Model{}, false
}
func pluginCatalog(pp plugin.Provider) []catalog.Model {
out := make([]catalog.Model, 0, len(pp.Models))
for _, m := range pp.Models {
c := catalog.Model{
ID: m.ID, Name: m.Name, Provider: pp.ID, Released: m.Released,
APIs: []string{string(pluginProtocol(pp.ID, m))}, Images: m.Image,
Context: m.Input, Output: m.Output,
}
if c.Context == 0 {
c.Context = m.Context
}
if c.Name == "" {
c.Name = m.ID
}
if m.Reasoning {
c.Efforts = m.Variants
}
if m.Cost != nil {
c.Price = &catalog.Price{Input: m.Cost.Input, Output: m.Cost.Output}
}
out = append(out, c)
}
return out
}
func pluginProvider(pp plugin.Provider) 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.models = func() []catalog.Model {
if cur, ok := PluginOf(id); ok {
return pluginCatalog(cur)
}
return pluginCatalog(pp)
}
a.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ps, err := plugin.Providers(ctx)
if err != nil {
return nil, err
}
for _, cur := range ps {
if cur.ID == pp.ID {
return catalog.Chat(pluginCatalog(cur)), nil
}
}
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}
for _, m := range pp.Models {
switch pluginProtocol(pp.ID, m) {
case Chat:
p.Chat = pluginBase + pp.ID + "/v1"
case Responses:
p.Responses = pluginBase + pp.ID + "/v1"
case Anthropic:
p.Anthropic = pluginBase + pp.ID
case CodeAssist:
a.codeAssist = pluginBase + pp.ID
}
}
return p
}
// pluginAPIs is the one API a plugin provider's model is spoken to on.
func (p Provider) pluginAPIs(model string) []Protocol {
pp := p.Account.plugin
if cur, ok := PluginOf(p.ID); ok {
pp = &cur
}
if m, ok := pluginModel(*pp, model); ok {
return []Protocol{pluginProtocol(pp.ID, m)}
}
return nil
}
// 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) {
ctx := req.Context()
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return nil, err
}
body = b
}
rest := strings.TrimPrefix(req.URL.String(), pluginBase+pp.ID)
model := bodyModel(body)
codeAssist := strings.HasPrefix(rest, "/v1internal:")
if codeAssist {
// Code Assist's envelope off: Gemini's own request, the model in
// the path, as @ai-sdk/google sends it
var env struct {
Model string `json:"model"`
Request json.RawMessage `json:"request"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
model, body = env.Model, env.Request
}
m, ok := pluginModel(pp, model)
if !ok {
if cur, found := PluginOf(PluginID(pp.ID)); found {
pp = cur
m, ok = pluginModel(pp, model)
}
}
if !ok {
m = plugin.Model{ID: model, NPM: pp.NPM}
}
api := modelAPIID(m)
if api != model && !codeAssist {
body = withModel(body, api)
}
o, err := plugin.LoaderOptions(ctx, pp.ID)
if err != nil {
return nil, err
}
base := firstOf(o.BaseURL, m.URL, pp.API, npmBase[m.NPM])
if base == "" {
return nil, errors.New(pp.Name + "'s plugin says nowhere to send its requests")
}
base = strings.TrimRight(base, "/")
var url string
switch {
case codeAssist:
url = base + "/models/" + api + ":streamGenerateContent?alt=sse"
case strings.HasPrefix(rest, "/v1/"):
// chat, responses and Anthropic's messages: the AI SDK's base
// ends where magpie's /v1 does
url = base + strings.TrimPrefix(rest, "/v1")
default:
url = base + rest
}
h := map[string]string{}
for k, vs := range req.Header {
if len(vs) > 0 && !strings.EqualFold(k, ConversationHeader) {
h[strings.ToLower(k)] = vs[0]
}
}
return plugin.Fetch(ctx, plugin.FetchRequest{
Provider: pp.ID, Model: api, NPM: m.NPM, URL: url, Method: req.Method,
Headers: h, Body: body, Session: req.Header.Get(ConversationHeader),
})
}
func modelAPIID(m plugin.Model) string {
if m.APIID != "" {
return m.APIID
}
return m.ID
}
func withModel(body []byte, model string) []byte {
var v map[string]json.RawMessage
if json.Unmarshal(body, &v) != nil {
return body
}
v["model"], _ = json.Marshal(model)
if b, err := json.Marshal(v); err == nil {
return b
}
return body
}
func firstOf(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
// Do sends req, through the account's own transport when it has one (a
// plugin's) and client otherwise.
func (p Provider) Do(client *http.Client, req *http.Request) (*http.Response, error) {
if p.Account != nil && p.Account.transport != nil {
return p.Account.transport(req)
}
req.Header.Del(ConversationHeader)
return client.Do(req)
}
+109
View File
@@ -0,0 +1,109 @@
package provider
import (
"context"
"errors"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
)
// StartPluginSignIn begins signing in to a plugin's provider (OpenCode's
// id) with its OAuth method, the method's questions answered: the page to
// 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.
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)
cancel()
if err != nil {
return SignInState{}, err
}
s := &signInFlow{done: make(chan struct{})}
s.st = SignInState{ID: randomToken(9), Agent: PluginID(id), State: "waiting", URL: a.URL, Instructions: a.Instructions}
wait, stop := context.WithTimeout(context.Background(), signInTimeout)
s.stop = stop
if a.Method == "code" {
s.st.PasteCode, s.plugin = true, a.Session
go func() {
<-wait.Done()
if errors.Is(wait.Err(), context.DeadlineExceeded) {
s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"})
}
}()
} else {
go func() {
saved, err := plugin.Finish(wait, a.Session, "")
s.pluginDone(saved, err)
}()
}
signIns.Lock()
for sid, o := range signIns.m {
if o.st.Agent == s.st.Agent {
o.finish(SignInState{State: "canceled"})
delete(signIns.m, sid)
}
}
signIns.m[s.st.ID] = s
signIns.Unlock()
return s.status(), nil
}
// pluginCode finishes a plugin's sign-in with the code its page showed.
func (s *signInFlow) pluginCode(code string) error {
code = strings.TrimSpace(code)
if code == "" {
return errors.New("paste the code the sign-in page showed")
}
s.mu.Lock()
session := s.plugin
s.plugin = ""
waiting := s.st.State == "waiting"
s.mu.Unlock()
if session == "" || !waiting {
return errors.New("this sign-in is over; start it again")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
saved, err := plugin.Finish(ctx, session, code)
s.pluginDone(saved, err)
return err
}
func (s *signInFlow) pluginDone(saved string, err error) {
switch {
case errors.Is(err, context.Canceled):
s.finish(SignInState{State: "canceled"})
case errors.Is(err, context.DeadlineExceeded):
s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"})
case err != nil:
s.finish(SignInState{State: "failed", Error: err.Error()})
default:
s.finish(SignInState{State: "done", User: pluginUser(saved)})
}
}
// PluginAPIKey signs in to a plugin's provider with a key, as its "api"
// method does; 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))
if err != nil {
return "", err
}
_ = ShowAccount(PluginID(saved))
return PluginID(saved), nil
}
func pluginUser(id string) string {
for _, pp := range plugin.Cached() {
if pp.ID == id {
return pp.AccountID
}
}
return ""
}
+94
View File
@@ -0,0 +1,94 @@
package provider
import (
"context"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/plugin"
)
// A plugin's sign-in is followed like a built-in one: the page, then the
// code pasted back (a wrong one fails the sign-in, which starts again);
// done, its provider is there, and removing it signs out. A key signs in
// at once.
func TestPluginSignIn(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)
}
in := map[string]string{"where": "work", "team": "blue"}
wait := func(id string) SignInState {
t.Helper()
for range 100 {
if st, _ := SignInStatus(id); st.State != "waiting" {
return st
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("the sign-in is still waiting")
return SignInState{}
}
st, err := StartPluginSignIn("fakeco", 1, in)
if err != nil || st.Agent != "fakeco" || st.State != "waiting" || !st.PasteCode || !strings.Contains(st.URL, "where=work") || st.Instructions != "Paste the code" {
t.Fatalf("StartPluginSignIn = %+v, %v", st, err)
}
if err := SubmitSignInCallback(st.ID, " "); err == nil {
t.Fatal("an empty code was taken")
}
if err := SubmitSignInCallback(st.ID, "bad"); err == nil {
t.Fatal("a wrong code was taken")
}
if got := wait(st.ID); got.State != "failed" {
t.Fatalf("after a wrong code: %+v", got)
}
if err := SubmitSignInCallback(st.ID, "good"); err == nil {
t.Fatal("a failed sign-in took another code")
}
st, _ = StartPluginSignIn("fakeco", 1, in)
if err := SubmitSignInCallback(st.ID, "good"); err != nil {
t.Fatal(err)
}
if got := wait(st.ID); got.State != "done" || got.User != "me@fake" {
t.Fatalf("after the code: %+v", got)
}
if p, err := Find("fakeco"); err != nil || !p.IsPlugin() {
t.Fatalf("Find = %+v, %v", p, err)
}
if err := Delete("fakeco"); err != nil {
t.Fatal(err)
}
if plugin.SignedIn("fakeco") {
t.Fatal("removing the account didn't sign out")
}
if _, err := Find("fakeco"); err == nil {
t.Fatal("fakeco is a provider after it was removed")
}
if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, " "); err == nil {
t.Fatal("an empty key was taken")
}
if id, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k1"); err != nil || id != "fakeco" {
t.Fatalf("PluginAPIKey = %q, %v", id, err)
}
if _, err := Find("fakeco"); err != nil {
t.Fatal(err)
}
}
+14 -1
View File
@@ -8,6 +8,7 @@
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -17,8 +18,10 @@ import (
"regexp"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/settings"
)
@@ -536,6 +539,12 @@ func ShowAccount(id string) error {
// Delete removes a provider. An account is only hidden from magpie (its
// model picks kept); signing out is the agent's job.
func Delete(id string) error {
if p, ok := find(Accounts(), id); ok && p.IsPlugin() {
// 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)
}
if _, ok := find(Accounts(), id); ok {
f := load()
for i := range f.Providers {
@@ -709,7 +718,7 @@ func (p Provider) Base(proto Protocol) string {
// Speaks lists the protocols the vendor serves natively, preferred first.
func (p Provider) Speaks() []Protocol {
// a Google sign-in speaks Code Assist, and only that
if p.Account != nil && p.Account.codeAssist != "" {
if p.Account != nil && p.Account.codeAssist != "" && !p.IsPlugin() {
return []Protocol{CodeAssist}
}
var out []Protocol
@@ -718,6 +727,10 @@ func (p Provider) Speaks() []Protocol {
out = append(out, pr)
}
}
// a plugin's Gemini models, beside what else it serves
if p.IsPlugin() && p.Account.codeAssist != "" {
out = append(out, CodeAssist)
}
return out
}
+11 -3
View File
@@ -53,6 +53,9 @@ type SignInState struct {
Code string `json:"code,omitempty"` // what to type there, for a device code
State string `json:"state"` // installing, waiting, done, failed or canceled
PasteCallback bool `json:"pasteCallback,omitempty"` // a callback URL can also finish this sign-in
// PasteCode is a plugin's sign-in finished by the code its page shows
PasteCode bool `json:"pasteCode,omitempty"`
Instructions string `json:"instructions,omitempty"` // a plugin's words for its page
// Installing is the CLI being installed before the sign-in can start
Installing string `json:"installing,omitempty"`
User string `json:"user,omitempty"` // the account, once done
@@ -71,6 +74,7 @@ type signInFlow struct {
stop func() // ends an agent's own login command, when that is the sign-in
kiro *kiroFlow
site string // where to sign in, for an agent with more than one (ZCode: "zai" or "bigmodel")
plugin string // a plugin's sign-in session, finished with the code pasted back
done chan struct{}
}
@@ -349,15 +353,19 @@ func CancelSignIn(id string) {
}
// SubmitSignInCallback finishes a browser sign-in whose callback could not
// reach this machine. No sign-in magpie makes now takes one (PasteCallback is
// never set); the route and the page's field stay for one that will.
// reach this machine, or a plugin's with the code its page showed. No
// built-in sign-in takes a callback now (PasteCallback is never set); the
// route and the page's field stay for one that will.
func SubmitSignInCallback(id, raw string) error {
signIns.Lock()
_, ok := signIns.m[id]
s, ok := signIns.m[id]
signIns.Unlock()
if !ok {
return errors.New("no such sign-in")
}
if s.plugin != "" {
return s.pluginCode(raw)
}
return errors.New("this sign-in can't be finished from a pasted address")
}
+1 -1
View File
@@ -287,7 +287,7 @@ func probe(ctx context.Context, p Provider, proto Protocol, url string, body []b
return r
}
start := time.Now()
res, err := http.DefaultClient.Do(req)
res, err := p.Do(http.DefaultClient, req)
r.Millis = time.Since(start).Milliseconds()
if err != nil {
r.Error = strings.TrimPrefix(err.Error(), "Post \""+url+"\": ")
+4
View File
@@ -74,6 +74,8 @@ const usage = `magpie — one place to pick every agent's model
magpie accounts refresh renew the saved Claude and ChatGPT sign-ins now (the gateway does it daily)
magpie accounts checkin WorkBuddy's daily check-in (签到) for each WorkBuddy account, now (Settings can do it daily)
magpie accounts project <gemini|antigravity> <email> <project> the Google Cloud project a Google account's requests go to
magpie plugin [add <package>|rm|update|on|off|login <provider>|logout <provider>]
OpenCode provider plugins: subscriptions signed in to, and served, through a plugin
magpie serve run the gateway alone (the app runs it too)
magpie mcp image the image generation MCP server an agent is given from the library (stdio)
@@ -216,6 +218,8 @@ func run(args []string) error {
return restoreCmd(args[1:])
case "webdav", "dav":
return webdavCmd(args[1:])
case "plugin", "plugins":
return pluginCmd(args)
case "mcp":
return imagemcp.Run(args[1:])
case "claude-mcp-helper": // internal: stdio MCP subprocess spawned by Claude Code
+318
View File
@@ -0,0 +1,318 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
const pluginUsage = `usage: magpie plugin [list] [--json]
magpie plugin add <npm package | path> install an OpenCode provider plugin (opencode-gemini-auth, ./my-plugin.js)
magpie plugin rm <name> remove one
magpie plugin update install the newest version of each
magpie plugin on|off <name> turn one on or off
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
magpie plugin logout <provider> forget the sign-in`
// pluginCmd: `magpie plugin …` — OpenCode's provider plugins, which sign in
// to a subscription and carry its requests (internal/plugin).
func pluginCmd(args []string) error {
sub := "list"
if len(args) > 1 {
sub = args[1]
}
rest := args[min(len(args), 2):]
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
switch sub {
case "list", "ls", "--json":
return listPlugins(ctx, sub == "--json" || len(rest) > 0 && rest[0] == "--json")
case "add", "install":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
if !plugin.IsPath(rest[0]) && !plugin.HasBun() {
fmt.Println(muted.Render("Downloading Bun " + plugin.BunVersion + ", which plugins run on…"))
}
e, err := plugin.Add(ctx, rest[0])
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "added", e.Spec)
return listPlugins(ctx, false)
case "rm", "remove", "uninstall":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
if err := plugin.Remove(ctx, rest[0]); err != nil {
return err
}
fmt.Println(green.Render("✓"), "removed", rest[0])
return nil
case "update", "upgrade":
if err := plugin.Update(ctx); err != nil {
return err
}
fmt.Println(green.Render("✓"), "plugins updated")
return listPlugins(ctx, false)
case "on", "off":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
if err := plugin.SetOff(rest[0], sub == "off"); err != nil {
return err
}
fmt.Println(green.Render("✓"), rest[0], "is", sub)
return nil
case "login", "signin":
if len(rest) < 1 || len(rest) > 2 {
return errors.New(pluginUsage)
}
method := ""
if len(rest) == 2 {
method = rest[1]
}
return pluginLogin(ctx, rest[0], method)
case "logout", "signout":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
pp, err := pluginProvider(ctx, rest[0])
if err != nil {
return err
}
if err := plugin.SignOut(ctx, pp.ID); err != nil {
return err
}
fmt.Println(green.Render("✓"), "signed out of", pp.Name)
return nil
case "help", "-h", "--help":
fmt.Println(pluginUsage)
return nil
}
return errors.New(pluginUsage)
}
func listPlugins(ctx context.Context, asJSON bool) error {
l := plugin.Load()
if len(l.Plugins) == 0 {
if asJSON {
fmt.Println("[]")
return nil
}
fmt.Println("No plugins. Add one: magpie plugin add <npm package | path>")
return nil
}
loaded, lerr := plugin.Plugins(ctx)
errs := map[string]string{}
for _, p := range loaded {
errs[p.Spec] = p.Error
}
ps, perr := plugin.Providers(ctx)
if asJSON {
b, _ := json.MarshalIndent(map[string]any{"plugins": l.Plugins, "loaded": loaded, "providers": ps}, "", " ")
fmt.Println(string(b))
return nil
}
for _, e := range l.Plugins {
state := green.Render("on")
switch {
case e.Off:
state = muted.Render("off")
case errs[e.Spec] != "":
state = "failed: " + errs[e.Spec]
}
fmt.Printf("%s %s\n", bold.Render(e.Spec), state)
for _, p := range ps {
if p.Spec != e.Spec {
continue
}
who := muted.Render("not signed in · magpie plugin login " + p.ID)
if p.SignedIn {
who = green.Render("signed in")
if p.AccountID != "" {
who += " as " + p.AccountID
}
}
fmt.Printf(" %s %s %s\n", provider.PluginID(p.ID), muted.Render("("+p.Name+", "+strconv.Itoa(len(p.Models))+" models)"), who)
}
}
if lerr != nil {
return lerr
}
return perr
}
// pluginProvider is the plugins' provider named by OpenCode's id or
// magpie's.
func pluginProvider(ctx context.Context, name string) (plugin.Provider, error) {
ps, err := plugin.Providers(ctx)
if err != nil {
return plugin.Provider{}, err
}
for _, p := range ps {
if p.ID == name || provider.PluginID(p.ID) == name || strings.EqualFold(p.Name, name) {
return p, nil
}
}
var ids []string
for _, p := range ps {
ids = append(ids, p.ID)
}
if len(ids) == 0 {
return plugin.Provider{}, fmt.Errorf("no plugin signs in to %q — add one: magpie plugin add <npm package>", name)
}
return plugin.Provider{}, fmt.Errorf("no plugin signs in to %q; they sign in to %s", name, strings.Join(ids, ", "))
}
// pluginLogin signs in to a plugin's provider as OpenCode's `auth login`
// does: the method, its questions, then a browser (and a code pasted
// back) or a key.
func pluginLogin(ctx context.Context, name, method string) error {
pp, err := pluginProvider(ctx, name)
if err != nil {
return err
}
if len(pp.Methods) == 0 {
return fmt.Errorf("%s's plugin has no way to sign in", pp.Name)
}
m := 0
switch {
case method != "":
m = -1
for i, x := range pp.Methods {
if strconv.Itoa(i+1) == method || strings.EqualFold(x.Label, method) || x.Type == method {
m = i
break
}
}
if m < 0 {
return fmt.Errorf("%s has no sign-in method %q", pp.Name, method)
}
case len(pp.Methods) > 1:
fmt.Println("How do you sign in to", pp.Name+"?")
for i, x := range pp.Methods {
fmt.Printf(" %d. %s\n", i+1, x.Label)
}
n, err := askNumber(len(pp.Methods))
if err != nil {
return err
}
m = n
}
inputs := map[string]string{}
for {
q, err := plugin.NextPrompt(ctx, pp.ID, m, inputs)
if err != nil {
return err
}
if q == nil {
break
}
for {
v, err := askPrompt(q)
if err != nil {
return err
}
msg, err := plugin.Validate(ctx, pp.ID, m, q.Key, v)
if err != nil {
return err
}
if msg == "" {
inputs[q.Key] = v
break
}
fmt.Println(msg)
}
}
var saved string
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 {
return err
}
} else {
a, err := plugin.Authorize(ctx, pp.ID, m, inputs)
if err != nil {
return err
}
if a.URL != "" {
fmt.Println("Sign in in your browser. If it didn't open, go to:")
fmt.Println(faint.Render(a.URL))
openInBrowser(a.URL)
}
if a.Instructions != "" {
fmt.Println(a.Instructions)
}
code := ""
if a.Method == "code" {
fmt.Print("Code: ")
line, err := stdin.ReadString('\n')
if err != nil && line == "" {
return err
}
code = strings.TrimSpace(line)
}
wait, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if saved, err = plugin.Finish(wait, a.Session, code); err != nil {
return err
}
}
fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+provider.PluginID(saved)+"/<model>"))
return nil
}
func askNumber(n int) (int, error) {
for {
fmt.Print("> ")
line, err := stdin.ReadString('\n')
if i, e := strconv.Atoi(strings.TrimSpace(line)); e == nil && i >= 1 && i <= n {
return i - 1, nil
}
if err != nil {
return 0, err
}
fmt.Printf("A number from 1 to %d\n", n)
}
}
func askPrompt(q *plugin.Prompt) (string, error) {
if q.Type == "select" {
fmt.Println(q.Message)
for i, o := range q.Options {
hint := ""
if o.Hint != "" {
hint = muted.Render(" " + o.Hint)
}
fmt.Printf(" %d. %s%s\n", i+1, o.Label, hint)
}
i, err := askNumber(len(q.Options))
if err != nil {
return "", err
}
return q.Options[i].Value, nil
}
p := q.Message
if q.Placeholder != "" {
p += " " + muted.Render("("+q.Placeholder+")")
}
fmt.Print(p + " ")
line, err := stdin.ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSpace(line), nil
}