diff --git a/README.md b/README.md
index 5fe159ad..ab23d617 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ Your Claude, ChatGPT, Copilot, Gemini or Grok sign-in becomes a provider that ev
**🧩 Plugins**
-OpenCode auth plugins and pi provider packages from npm run in magpie as they do in their own apps. Any plan a plugin signs in to works in every agent.
+OpenCode auth plugins and pi provider packages from npm run in magpie as they do in their own apps. Any plan a plugin signs in to works in every agent. A plugin can also be gateway middleware that reads and rewrites every request and reply.
@@ -167,6 +167,32 @@ magpie plugin login google-plugin # its own sign-in flow, in magpie
magpie runs plugins on [Bun](https://bun.sh), which it downloads the first time a plugin needs it. A plugin signs in, lists its models and makes each request. Agents use its models like any other provider's. Browse the community plugins at **[magpie-community/plugins](https://github.com/magpie-community/plugins)**, or [write your own](https://usemagpie.ai/docs/plugins).
+A plugin can also be **gateway middleware**: JavaScript run inside magpie's gateway on what every agent sends and gets back, whatever the provider. `onRequest` can rewrite a request or turn it away, `onEvent` sees each streamed event, and `onResponse` sees a whole reply. It runs in-process, so an event costs about a microsecond, and a hook that throws or runs too long leaves the request as it was.
+
+```js
+// alias.middleware.js — magpie plugin add ./alias.middleware.js
+export function onRequest(body, ctx) {
+ if (body.model === "fast") return { ...body, model: "deepseek/deepseek-chat" };
+}
+```
+
+Ready-made middleware, most of it what [New API](https://github.com/QuantumNous/new-api) does for its channels, with the same JSON, is under Plugins › Discover › Gateway middleware:
+
+| Package | What it does |
+|---|---|
+| `param-override` | New API's `param_override`: set, delete, move or rewrite request fields, under conditions, or turn a request away |
+| `model-map` | New API's `model_mapping`: send a model under another name; replies keep the name asked for |
+| `system-prompt` | Your system prompt on every request, or some agents' or models' |
+| `word-guard` | New API's sensitive-word filter: turn away or mask words in what users send, and in replies |
+| `think-tags` | Take `…` out of replies, or put `reasoning_content` into them |
+
+```sh
+magpie plugin add @magpie-community/middleware-model-map
+magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'
+```
+
+See [Gateway middleware](https://usemagpie.ai/docs/plugins#middleware).
+
## Usage and cost tracking
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 9cdf2ab0..9aee944c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -62,7 +62,7 @@ magpie 把这些都收到一处:
@@ -167,6 +167,32 @@ magpie plugin login google-plugin # 在 magpie 里走插件自己的登
插件运行在 [Bun](https://bun.sh) 上,magpie 会在第一次需要时自动下载 Bun。插件负责登录、列出模型和发送请求,Agent 像用其他 provider 一样用它的模型。社区插件在 **[magpie-community/plugins](https://github.com/magpie-community/plugins)**,你也可以[自己写一个](https://usemagpie.ai/docs/zh/plugins)。
+插件也可以是**网关中间件**:在 magpie 网关里运行的 JavaScript,处理每个 Agent 发出和收到的内容,不管走的是哪个 provider。`onRequest` 可以改写请求或拒绝它,`onEvent` 处理流式回复的每个事件,`onResponse` 处理完整回复。它在进程内运行,每个事件约一微秒;钩子出错或超时,请求按原样放行。
+
+```js
+// alias.middleware.js — magpie plugin add ./alias.middleware.js
+export function onRequest(body, ctx) {
+ if (body.model === "fast") return { ...body, model: "deepseek/deepseek-chat" };
+}
+```
+
+现成的中间件在「插件 › 发现 › 网关中间件」里,大多是 [New API](https://github.com/QuantumNous/new-api) 为渠道提供的功能,JSON 也一样:
+
+| 包 | 作用 |
+|---|---|
+| `param-override` | New API 的参数覆盖(`param_override`):按条件设置、删除、移动或改写请求字段,或拒绝请求 |
+| `model-map` | New API 的模型重定向(`model_mapping`):换个模型名发出,回复里仍是请求的名字 |
+| `system-prompt` | 给每个请求(或某些 agent、模型)加上你的系统提示词 |
+| `word-guard` | New API 的敏感词过滤:用户发送的内容里有敏感词时拒绝或打码,回复也可打码 |
+| `think-tags` | 去掉回复里的 `…`,或把 `reasoning_content` 放进正文 |
+
+```sh
+magpie plugin add @magpie-community/middleware-model-map
+magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'
+```
+
+详见[网关中间件](https://usemagpie.ai/docs/zh/plugins#middleware)。
+
## 用量和费用统计
diff --git a/docs/reference.md b/docs/reference.md
index 4f49b9d6..6ea8b7f2 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -391,6 +391,26 @@ event) leaves what it was given as it was. Settings → Plugins shows each
middleware's calls, time and failures. The [plugin guide](https://usemagpie.ai/docs/plugins#middleware)
has the rest.
+`ctx.options` is the middleware's entry's `options` in `plugins.json`: set
+them with its **Options** button in Plugins › Installed, or
+
+```sh
+magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'
+magpie plugin options model-map off # clears them
+```
+
+The name can be a package's short name (`model-map` for
+`@magpie-community/middleware-model-map`). A package's
+`"magpie": { "options": {…} }` is the example the editor starts from.
+
+Ready-made middleware, under Plugins › Discover › Gateway middleware
+(`@magpie-community/middleware-`), mostly New API's channel settings
+with the same JSON: `param-override` (`param_override`), `model-map`
+(`model_mapping`), `system-prompt`, `word-guard` (sensitive words) and
+`think-tags` (strip `…`, or `thinking_to_content`). A
+middleware's entry in the community `registry.json` has
+`"kind": "middleware"` and no `providers`.
+
### What a model costs
A call is counted at its **effective price**: what you set for that provider
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index 636ada5a..70ba5187 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -5,6 +5,7 @@ These pages describe current responsibilities, runtime paths, state, and contrac
| Subsystem | Reference |
| --- | --- |
| Built-in subscriptions moving to plugins | [Provider and plugin ownership](provider-plugins.md) |
+| Plugins that run on the gateway's requests and replies | [Gateway middleware](gateway-middleware.md) |
A subsystem can span several packages or repositories. Its reference describes the behavior those parts provide together; it does not need to list every function.
diff --git a/docs/subsystems/gateway-middleware.md b/docs/subsystems/gateway-middleware.md
new file mode 100644
index 00000000..edcec371
--- /dev/null
+++ b/docs/subsystems/gateway-middleware.md
@@ -0,0 +1,53 @@
+# Gateway middleware
+
+A plugin can be gateway middleware: JavaScript that magpie runs inside its gateway on the requests agents send and the replies they get back, whatever provider serves them. It is separate from the provider plugins in [Provider and plugin ownership](provider-plugins.md), which run on Bun in the plugin host. Middleware runs in the gateway's own process on [moejs](https://github.com/Calcium-Ion/moejs), a JavaScript engine written in Go, because it sits on every request and every streamed event.
+
+## Responsibilities and sources of truth
+
+| Part | Responsibility | Source |
+| --- | --- | --- |
+| Discovery | Find a plugin's middleware file: `magpie.middleware` in its package.json, or a path to a `*.middleware.js` / `.mjs` file | `plugin.Middleware` in [`internal/plugin/middleware.go`](../../internal/plugin/middleware.go) |
+| Bun host | Skip a plugin that is middleware only (no `main`, no `exports`) | `Middleware(...)` check in [`host.go`](../../internal/plugin/host.go) |
+| Load and run | Compile, pool runtimes, call hooks with limits, count calls and failures | [`internal/middleware`](../../internal/middleware): `middleware.go`, `run.go` |
+| Gateway | Run the chain on agents' requests only | `serveAgent` in [`internal/gateway/middleware.go`](../../internal/gateway/middleware.go), called from `gateway.go` and `codex_backend.go` |
+| GUI and CLI | Show each middleware's hooks, calls, µs per call, failures and load error | `middleware.States()`; `pluginEntryJSON.Middleware` in [`internal/gui/plugins.go`](../../internal/gui/plugins.go), `mwLine` in `plugins.js`, `listPlugins` in [`plugins_cli.go`](../../plugins_cli.go) |
+| Options | A middleware's `options` in plugins.json, as `ctx.options`; set by name, spec or short name; a package's `magpie.options` is only the editor's example | `SetOptions`, `ShortName` in [`store.go`](../../internal/plugin/store.go); `pluginOptions` in `plugins_cli.go`; `optionsEditor` in `plugins.js` |
+| Kind | Whether a plugin is a provider, middleware or both, shown on every card and row, also for one that is off | `isMiddleware`, `middlewareOnly` in `pluginEntryJSON`; `kind: "middleware"` market entries (`market.json`, the community `registry.json`); `kindChip` in `plugins.js` |
+| Community middleware | Ready-made packages (`@magpie-community/middleware-`), mostly New API's channel settings | `packages/{param-override,model-map,system-prompt,word-guard,think-tags}` in [magpie-community/plugins](https://github.com/magpie-community/plugins), tested with `scripts/middleware.mjs` |
+| User docs | The hooks, `ctx` and an example for plugin authors | [usemagpie.ai/docs/plugins#middleware](https://usemagpie.ai/docs/plugins#middleware) (`site/public/docs/*plugins.html`) |
+
+plugins.json is the source of truth for which middleware is installed and on, in its order; a middleware that is off isn't loaded.
+
+## Runtime path
+
+1. `serveAgent` calls `middleware.Begin` with the request's protocol, model, stream flag, path and agent. With no middleware it is `serve` as before, at no cost beyond the check.
+2. `Run.Request` passes the agent's body, in its own API, to each `onRequest` in turn. An object returned is the body the next middleware and the gateway get; `undefined` keeps the bytes as they were. `ctx.reject(status, message)` stops the chain, and the agent gets that error in its API's shape (`writeError`).
+3. `Run.Wrap` wraps the response writer when some middleware has `onEvent` or `onResponse`. An SSE reply is split into events. Each event whose data is a JSON object goes to every `onEvent` that wants its name (`export const events`): an object replaces it, `null` drops it, and `undefined` writes the original bytes. `data: [DONE]` and other non-object data pass by. A whole JSON reply is buffered for `onResponse`, with `ctx.status` set. A reply with a `Content-Encoding` passes as-is.
+4. `Run.End` returns the runtimes to their pools.
+
+Each middleware gets one runtime for the whole request, so its hooks share `ctx.state`. Runtimes come from a per-middleware `sync.Pool` and modules are compiled once per load.
+
+Only requests agents send go through `serveAgent`. magpie's own requests (thread titles, the router's classifier, the search and vision stand-ins) call `serve` directly. Middleware sees bodies before redaction, which still applies upstream.
+
+## Constraints and failure behavior
+
+- Limits: 250ms for `onRequest` and `onResponse`, 50ms for each `onEvent`, 1s to load. A hook past its limit is interrupted.
+- Fail open: a hook that throws, returns something that is neither an object, `null` nor `undefined`, or is interrupted leaves the request or event as it was. The failure is counted and kept as the last error, and logged at most 60 lines a minute.
+- A middleware imports only relative files; packages must be bundled into it.
+- Reload: a change to plugins.json or to a middleware file is picked up by the next request at most a second later (`stamp`).
+- Stats count the whole call, including parsing JSON into the runtime and writing it back. Calls are counted in the process that runs the gateway, so `magpie plugin` shows hooks and load errors only.
+
+Measured with a real account and real Claude Code: about 1–1.6µs an event, 0.2–0.7ms an `onRequest` that parses and rewrites Claude Code's ~80KB body. `BenchmarkEvent` is about 0.9µs, 11 allocations.
+
+## Verification
+
+```sh
+go test -tags nogui ./internal/middleware
+go test -tags nogui -run TestMiddlewareThroughGateway ./internal/gateway
+go test -tags nogui -run TestPluginListSaysMiddleware .
+go test -tags nogui -run "TestSetOptionsShortName|TestBuiltinMarket" ./internal/plugin
+go test -tags nogui -bench Event ./internal/middleware
+node --test internal/gui/tests/plugin-middleware.test.cjs
+# in magpie-community/plugins: each package runs its cases.json through the hooks
+bun test packages/think-tags packages/model-map packages/param-override packages/system-prompt packages/word-guard
+```
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 2c2af75b..40dd1192 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -3265,9 +3265,11 @@ const I18N = {
"Type two letters or more to search npm": "输入两个及以上字符以搜索 npm",
"Nothing else on npm": "npm 上没有更多了",
"No plugin called “{q}”": "没有叫「{q}」的插件",
- "Subscriptions, as plugins": "以插件接入的订阅",
+ "Subscriptions and gateway middleware": "订阅与网关中间件",
+ "A plugin can also be middleware in magpie's gateway: it reads and rewrites what agents send and get back, for every provider.": "插件也可以是 magpie 网关里的中间件:读取并改写 Agent 发出和收到的内容,对每个 provider 都有效。",
+ "Write a middleware": "编写中间件",
"Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.": "插件负责登录编程套餐并发出请求,其模型可像其他供应商的一样用于所有 agent。插件为 OpenCode 的供应商插件或 pi 扩展包,运行在 Bun 上。",
- "A plugin is someone else's code with your sign-in: install the ones you trust.": "插件是他人的代码,会拿到你的登录:只安装你信任的。",
+ "A plugin is someone else's code with your sign-in or your requests: install the ones you trust.": "插件是他人的代码,会拿到你的登录或看到你的请求:只安装你信任的。",
"Have one in mind?": "已有想装的?",
"npm package or a folder on this computer": "npm 包名,或本机上的文件夹",
"npm package, GitHub repo (github:owner/repo) or a folder": "npm 包名、GitHub 仓库(github:owner/repo)或本机文件夹",
@@ -3276,7 +3278,7 @@ const I18N = {
"Choose a folder on this computer": "选择本机上的一个文件夹",
"Choose a folder": "选择文件夹",
"No plugins yet": "还没有插件",
- "Find a subscription in Discover and install it; it signs in from here.": "在「发现」中找到订阅并安装,然后在这里登录。",
+ "Install a subscription or a middleware from Discover; a subscription signs in from here.": "在「发现」中安装订阅或中间件;订阅在这里登录。",
"Discover plugins": "发现插件",
"Plugins run on Bun {v}": "插件运行在 Bun {v} 上",
"Update all ({n})": "全部更新({n})",
@@ -3298,6 +3300,19 @@ const I18N = {
"magpie updates it by itself within the hour; Update does it now": "一小时内自动更新;点“更新”立即更新",
"Auto-updated": "已自动更新",
"Middleware": "中间件",
+ "Gateway middleware": "网关中间件",
+ "Options": "选项",
+ "The options it runs with": "它运行时用的选项",
+ "It runs with no options; its package suggests some": "它没有设置选项,包里有建议的选项",
+ "Applied to the next request": "下一个请求起生效",
+ "Not set: these are its package's example": "未设置:这是包里给的示例",
+ "Not JSON: {error}": "不是 JSON:{error}",
+ "Options are a JSON object": "选项须是一个 JSON 对象",
+ "{name}'s options saved": "{name} 的选项已保存",
+ "{name}'s options cleared": "{name} 的选项已清空",
+ "Runs it with no options": "不带选项运行它",
+ "Signs in to a subscription and serves its models": "登录一个订阅,提供它的模型",
+ "runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it": "在 magpie 的网关里处理每个 agent 发出的请求和收到的回复,不论由哪个供应商提供",
"Runs in magpie's gateway on the requests your agents send and on the replies": "在 magpie 网关里运行,处理智能体发出的请求和收到的回复",
"Middleware didn't load: {error}": "中间件没有加载:{error}",
"Gateway middleware: {hooks}": "网关中间件:{hooks}",
@@ -3318,7 +3333,7 @@ const I18N = {
"Updated": "更新于",
"Signs in to": "登录",
"Source": "源码",
- "Plugins are other people's code: they sign in and make the requests.": "插件是他人的代码:由它登录并发出请求。",
+ "Plugins are other people's code: they sign in and make the requests, or see them as middleware.": "插件是他人的代码:由它登录并发出请求,或作为中间件看到请求。",
"No README": "没有 README",
"npm didn't answer: {error}": "npm 没有响应:{error}",
"Couldn't read the folder's README: {error}": "无法读取该文件夹的 README:{error}",
@@ -6563,9 +6578,11 @@ const I18N = {
"Type two letters or more to search npm": "2 文字以上入力して npm を検索",
"Nothing else on npm": "npm に他のものはありません",
"No plugin called “{q}”": "「{q}」という名前のプラグインはありません",
- "Subscriptions, as plugins": "プラグインとしてのサブスクリプション",
+ "Subscriptions and gateway middleware": "サブスクリプションとゲートウェイミドルウェア",
+ "A plugin can also be middleware in magpie's gateway: it reads and rewrites what agents send and get back, for every provider.": "プラグインは magpie のゲートウェイのミドルウェアにもなれます:エージェントが送受信する内容を、どのプロバイダでも読み取り・書き換えます。",
+ "Write a middleware": "ミドルウェアを書く",
"Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.": "プラグインはコーディングプランにサインインしてリクエストを送ります。そのモデルは他のプロバイダと同様にすべてのエージェントで使えます。これらは OpenCode のプロバイダプラグインまたは pi のパッケージで、Bun 上で動作します。",
- "A plugin is someone else's code with your sign-in: install the ones you trust.": "プラグインはあなたのサインイン情報を扱う他人のコードです:信頼できるものだけをインストールしてください。",
+ "A plugin is someone else's code with your sign-in or your requests: install the ones you trust.": "プラグインはあなたのサインイン情報やリクエストを扱う他人のコードです:信頼できるものだけをインストールしてください。",
"Have one in mind?": "入れたいものがありますか?",
"npm package or a folder on this computer": "npm パッケージ、またはこのコンピュータ上のフォルダ",
"npm package, GitHub repo (github:owner/repo) or a folder": "npm パッケージ、GitHub リポジトリ(github:owner/repo)、またはフォルダ",
@@ -6573,7 +6590,7 @@ const I18N = {
"Choose a folder on this computer": "このコンピュータ上のフォルダを選択",
"Choose a folder": "フォルダを選択",
"No plugins yet": "プラグインはまだありません",
- "Find a subscription in Discover and install it; it signs in from here.": "「発見」でサブスクリプションを見つけてインストールすると、ここからサインインできます。",
+ "Install a subscription or a middleware from Discover; a subscription signs in from here.": "「発見」からサブスクリプションやミドルウェアをインストールします。サブスクリプションはここからサインインします。",
"Discover plugins": "プラグインを探す",
"Plugins run on Bun {v}": "プラグインは Bun {v} で動作します",
"Update all ({n})": "すべてアップデート({n})",
@@ -6595,6 +6612,19 @@ const I18N = {
"magpie updates it by itself within the hour; Update does it now": "magpie が1時間以内に自動でアップデートします。「アップデート」で今すぐ実行します",
"Auto-updated": "自動アップデート済み",
"Middleware": "ミドルウェア",
+ "Gateway middleware": "ゲートウェイミドルウェア",
+ "Options": "オプション",
+ "The options it runs with": "実行時に使うオプション",
+ "It runs with no options; its package suggests some": "オプションは未設定です。パッケージに推奨のオプションがあります",
+ "Applied to the next request": "次のリクエストから適用されます",
+ "Not set: these are its package's example": "未設定:パッケージの例です",
+ "Not JSON: {error}": "JSON ではありません:{error}",
+ "Options are a JSON object": "オプションは JSON オブジェクトです",
+ "{name}'s options saved": "{name} のオプションを保存しました",
+ "{name}'s options cleared": "{name} のオプションをクリアしました",
+ "Runs it with no options": "オプションなしで実行します",
+ "Signs in to a subscription and serves its models": "サブスクリプションにサインインし、そのモデルを提供します",
+ "runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it": "どのプロバイダが応答しても、magpie のゲートウェイで各エージェントの送受信を処理します",
"Runs in magpie's gateway on the requests your agents send and on the replies": "magpie のゲートウェイで、エージェントが送るリクエストと返ってくる応答に対して動きます",
"Middleware didn't load: {error}": "ミドルウェアを読み込めませんでした:{error}",
"Gateway middleware: {hooks}": "ゲートウェイミドルウェア:{hooks}",
@@ -6615,7 +6645,7 @@ const I18N = {
"Updated": "更新日",
"Signs in to": "サインイン先",
"Source": "ソース",
- "Plugins are other people's code: they sign in and make the requests.": "プラグインは他人のコードです:プラグインがサインインし、リクエストを送ります。",
+ "Plugins are other people's code: they sign in and make the requests, or see them as middleware.": "プラグインは他人のコードです:サインインしてリクエストを送るか、ミドルウェアとしてリクエストを見ます。",
"No README": "README がありません",
"npm didn't answer: {error}": "npm から応答がありません:{error}",
"Couldn't read the folder's README: {error}": "フォルダの README を読めませんでした:{error}",
@@ -9870,9 +9900,11 @@ const I18N = {
"Type two letters or more to search npm": "Mindestens zwei Zeichen eingeben, um npm zu durchsuchen",
"Nothing else on npm": "Sonst nichts auf npm",
"No plugin called “{q}”": "Keine Erweiterung namens „{q}“",
- "Subscriptions, as plugins": "Abos als Erweiterungen",
+ "Subscriptions and gateway middleware": "Abos und Gateway-Middleware",
+ "A plugin can also be middleware in magpie's gateway: it reads and rewrites what agents send and get back, for every provider.": "Eine Erweiterung kann auch Middleware im Gateway von magpie sein: Sie liest und ändert, was Agenten senden und erhalten, bei jedem Anbieter.",
+ "Write a middleware": "Eine schreiben",
"Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.": "Erweiterungen melden sich bei Coding-Abos an und stellen deren Anfragen; die Modelle funktionieren dann in jedem Agenten wie die jedes anderen Anbieters. Es sind Anbietererweiterungen von OpenCode oder Pakete von pi, ausgeführt auf Bun.",
- "A plugin is someone else's code with your sign-in: install the ones you trust.": "Eine Erweiterung ist fremder Code mit Ihrer Anmeldung: Installieren Sie nur solche, denen Sie vertrauen.",
+ "A plugin is someone else's code with your sign-in or your requests: install the ones you trust.": "Eine Erweiterung ist fremder Code mit Ihrer Anmeldung oder Ihren Anfragen: Installieren Sie nur solche, denen Sie vertrauen.",
"Have one in mind?": "Schon eines im Sinn?",
"npm package or a folder on this computer": "npm-Paket oder ein Ordner auf diesem Computer",
"npm package, GitHub repo (github:owner/repo) or a folder": "npm-Paket, GitHub-Repo (github:owner/repo) oder ein Ordner",
@@ -9880,7 +9912,7 @@ const I18N = {
"Choose a folder on this computer": "Einen Ordner auf diesem Computer wählen",
"Choose a folder": "Ordner wählen",
"No plugins yet": "Noch keine Erweiterungen",
- "Find a subscription in Discover and install it; it signs in from here.": "Suchen Sie unter Entdecken ein Abo und installieren Sie es; die Anmeldung erfolgt hier.",
+ "Install a subscription or a middleware from Discover; a subscription signs in from here.": "Installieren Sie unter Entdecken ein Abo oder eine Middleware; ein Abo meldet sich hier an.",
"Discover plugins": "Erweiterungen entdecken",
"Plugins run on Bun {v}": "Erweiterungen laufen auf Bun {v}",
"Update all ({n})": "Alle aktualisieren ({n})",
@@ -9902,6 +9934,19 @@ const I18N = {
"magpie updates it by itself within the hour; Update does it now": "magpie aktualisiert es innerhalb einer Stunde selbst; „Aktualisieren“ tut es sofort",
"Auto-updated": "Automatisch aktualisiert",
"Middleware": "Middleware",
+ "Gateway middleware": "Gateway-Middleware",
+ "Options": "Optionen",
+ "The options it runs with": "Die Optionen, mit denen es läuft",
+ "It runs with no options; its package suggests some": "Es läuft ohne Optionen; sein Paket schlägt welche vor",
+ "Applied to the next request": "Gilt ab der nächsten Anfrage",
+ "Not set: these are its package's example": "Nicht gesetzt: das Beispiel seines Pakets",
+ "Not JSON: {error}": "Kein JSON: {error}",
+ "Options are a JSON object": "Optionen sind ein JSON-Objekt",
+ "{name}'s options saved": "Optionen von {name} gespeichert",
+ "{name}'s options cleared": "Optionen von {name} geleert",
+ "Runs it with no options": "Lässt es ohne Optionen laufen",
+ "Signs in to a subscription and serves its models": "Meldet sich bei einem Abo an und stellt dessen Modelle bereit",
+ "runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it": "läuft in magpies Gateway auf allem, was Agenten senden und zurückbekommen, egal welcher Anbieter antwortet",
"Runs in magpie's gateway on the requests your agents send and on the replies": "Läuft im Gateway von magpie, auf den Anfragen deiner Agents und auf den Antworten",
"Middleware didn't load: {error}": "Middleware nicht geladen: {error}",
"Gateway middleware: {hooks}": "Gateway-Middleware: {hooks}",
@@ -9922,7 +9967,7 @@ const I18N = {
"Updated": "Aktualisiert",
"Signs in to": "Meldet sich an bei",
"Source": "Quelle",
- "Plugins are other people's code: they sign in and make the requests.": "Erweiterungen sind fremder Code: Sie melden sich an und stellen die Anfragen.",
+ "Plugins are other people's code: they sign in and make the requests, or see them as middleware.": "Erweiterungen sind fremder Code: Sie melden sich an und stellen die Anfragen oder sehen sie als Middleware.",
"No README": "Keine README",
"npm didn't answer: {error}": "npm hat nicht geantwortet: {error}",
"Couldn't read the folder's README: {error}": "README des Ordners konnte nicht gelesen werden: {error}",
diff --git a/internal/gui/assets/plugins.css b/internal/gui/assets/plugins.css
index a05ac0e5..70a955fc 100644
--- a/internal/gui/assets/plugins.css
+++ b/internal/gui/assets/plugins.css
@@ -84,6 +84,10 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
.pm-chip.warn .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.pm-foot .pm-check { display: inline-flex; align-items: center; gap: 5px; }
.pm-foot .pm-check .spin { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; }
+.pm-chip.kind { gap: 3px; flex: none; }
+.pm-chip.kind svg { flex: none; }
+.pm-chip.kind.pv { background: var(--green-soft); color: var(--green); }
+.pm-chip.kind.mw { background: var(--accent-soft); color: var(--accent); }
.pm-chip.soft { background: none; box-shadow: inset 0 0 0 1px var(--line); color: var(--muted); }
/* a plugin's one button */
@@ -141,6 +145,16 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
.pm-sub { display: inline-flex; align-items: center; gap: 5px; }
.pm-sub .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--faint); }
.pm-sub.in .dot { background: var(--green); }
+.pm-opts { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.pm-opts textarea {
+ width: 100%; box-sizing: border-box; resize: vertical; padding: 8px 10px; border-radius: 8px;
+ border: 1px solid var(--line); background: var(--card); color: var(--fg); font: 12px/1.5 var(--code); tab-size: 2; outline: none;
+}
+.pm-opts textarea:focus { border-color: var(--accent); }
+.pm-opts-err { font-size: 11.5px; color: var(--red); }
+.pm-opts-err:empty { display: none; }
+.pm-opts-bar { display: flex; align-items: center; gap: 2px; }
+.pm-opts-bar > span { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); }
.pm-row .val { display: flex; align-items: center; gap: 2px; flex: none; }
.pm-row.off .who, .pm-row.off .pm-logo { opacity: .5; }
.pm-foot { display: flex; align-items: center; gap: 8px; padding: 0 4px; font-size: 11px; color: var(--faint); }
diff --git a/internal/gui/assets/plugins.js b/internal/gui/assets/plugins.js
index 74a639ae..a419fff4 100644
--- a/internal/gui/assets/plugins.js
+++ b/internal/gui/assets/plugins.js
@@ -22,6 +22,7 @@
let hits = null; // npm's answer for query: { q, list } | { q, loading } | { q, error }
let searchTimer = 0;
const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
+ let editing = null; // { pkg, text, error }: a middleware's options being edited in its row
let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
let checking = false; // Check for updates: npm being asked now
let checked = null; // ...and what it said: { at, by: spec → check }
@@ -31,6 +32,8 @@
const DOWN = "M8 3v7.5 M4.8 7.6 8 10.8l3.2-3.2 M3.5 13h9";
const SHIELD = "M8 2.3 13 4v3.8c0 3-2.1 5.1-5 5.9-2.9-.8-5-2.9-5-5.9V4z M5.8 8l1.6 1.6 2.9-3";
const OUTL = "M9.5 3.5h3v3 M12.5 3.5 7.5 8.5 M11 9.5v3H3.5V5h3";
+ const PLUG = "M6 2v3 M10 2v3 M4.5 5h7v2.5a3.5 3.5 0 0 1-7 0z M8 11v3";
+ const PIPE = "M1.5 8h3 M11.5 8h3 M4.5 4.5h7v7h-7z";
const FOLDER = "M14.7 12.7a1.3 1.3 0 0 1-1.3 1.3H2.6a1.3 1.3 0 0 1-1.3-1.3V3.3A1.3 1.3 0 0 1 2.6 2h3.3l1.3 2h6.2a1.3 1.3 0 0 1 1.3 1.3z";
const name = (spec) => { const i = spec.lastIndexOf("@"); return i > 0 && !spec.startsWith(".") && !spec.includes("/", i) ? spec.slice(0, i) : spec; };
@@ -65,10 +68,19 @@
};
function glyph(d, size = 14, stroke = 1.5) { return svg(d, size, stroke); }
- function logo(ic, big) {
+ // kindChip says what a plugin is: a provider signs in to a subscription
+ // and serves its models; middleware runs in the gateway on every agent's
+ // requests and replies, whatever serves them
+ function kindChip(mw) {
+ const c = el("span", "pm-chip kind " + (mw ? "mw" : "pv"));
+ c.append(glyph(mw ? PIPE : PLUG, 10, 1.5), el("span", "", mw ? t("Middleware") : t("Provider")));
+ c.title = mw ? t("Runs in magpie's gateway on the requests your agents send and on the replies") : t("Signs in to a subscription and serves its models");
+ return c;
+ }
+ function logo(ic, big, mw) {
const box = el("span", "pm-logo" + (big ? " big" : ""));
if (ic) box.append(icon(ic));
- else box.append(glyph(PUZZLE, big ? 22 : 17, 1.4));
+ else box.append(glyph(mw ? PIPE : PUZZLE, big ? 22 : 17, 1.4));
return box;
}
@@ -358,9 +370,10 @@
by.append(v);
} else by.append(el("span", "", l.npm?.publisher || l.package));
who.append(by);
- top.append(logo(l.icon), who, actionFor(l.package, l.name, l));
+ top.append(logo(l.icon, false, l.kind === "middleware"), who, actionFor(l.package, l.name, l));
const sum = el("p", "pm-sum", summary(l));
const meta = el("div", "pm-meta");
+ meta.append(kindChip(l.kind === "middleware"));
if (l.npm?.weekly) {
const d = el("span", "pm-dl");
d.append(glyph(DOWN, 11, 1.5), el("span", "", t("{n}/week", { n: count(l.npm.weekly) })));
@@ -532,8 +545,10 @@
if (!f) {
body.append(intro());
// magpie's community's alone: others' plugins are found by a search
- const ours = ls.filter((l) => l.community);
- if (ours.length) body.append(section(t("magpie community"), t("written for magpie, checked against its own sign-ins"), ours));
+ const ours = ls.filter((l) => l.community && l.kind !== "middleware");
+ const mws = ls.filter((l) => l.community && l.kind === "middleware");
+ if (ours.length) body.append(section(t("Subscriptions"), t("written for magpie, checked against its own sign-ins"), ours));
+ if (mws.length) body.append(section(t("Gateway middleware"), t("runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it"), mws));
body.append(manual());
return;
}
@@ -559,10 +574,19 @@
function intro() {
const box = el("div", "pm-intro");
const text = el("div", "pm-introtext");
- text.append(el("h2", "", t("Subscriptions, as plugins")));
+ text.append(el("h2", "", t("Subscriptions and gateway middleware")));
text.append(el("p", "", t("Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.")));
+ const mw = el("p", "");
+ mw.append(document.createTextNode(t("A plugin can also be middleware in magpie's gateway: it reads and rewrites what agents send and get back, for every provider.") + " "));
+ // the docs in the reader's language: the site has en, zh and ja
+ const how = el("a", "pm-link", "");
+ how.href = "https://usemagpie.ai/docs/" + ({ zh: "zh/", ja: "ja/" }[lang()] || "") + "plugins#middleware";
+ how.append(el("span", "", t("Write a middleware")), glyph(OUTL, 11, 1.5));
+ how.onclick = (ev) => { ev.preventDefault(); api("open", { url: how.href }).catch(() => {}); };
+ mw.append(how);
+ text.append(mw);
const trust = el("p", "pm-trust");
- trust.append(glyph(SHIELD, 12, 1.5), el("span", "", t("A plugin is someone else's code with your sign-in: install the ones you trust.")));
+ trust.append(glyph(SHIELD, 12, 1.5), el("span", "", t("A plugin is someone else's code with your sign-in or your requests: install the ones you trust.")));
text.append(trust);
const art = el("div", "pm-art");
for (const ic of ["zcode", "githubcopilot", "gemini-color", "zed", "kiro-color"]) art.append(logo(ic));
@@ -633,7 +657,7 @@
const es = mine.plugins || [];
if (!es.length) {
const none = el("div", "pm-none");
- none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Find a subscription in Discover and install it; it signs in from here.")));
+ none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Install a subscription or a middleware from Discover; a subscription signs in from here.")));
const b = el("button", "pm-act get", t("Discover plugins"));
b.onclick = () => { tab = "discover"; draw(); };
none.append(b);
@@ -722,11 +746,8 @@
// gateway middleware runs in magpie's gateway, not as a subscription:
// the row says so, and how it is doing, on a line of its own
const mw = e.middleware;
- if (mw && !mw.error && !e.off) {
- const c = el("span", "pm-chip soft", t("Middleware"));
- c.title = t("Runs in magpie's gateway on the requests your agents send and on the replies");
- nm.append(c);
- }
+ if (mw || e.isMiddleware) nm.append(kindChip(true));
+ if (e.providers?.length || !(e.middlewareOnly || (mw && !e.isMiddleware))) nm.append(kindChip(false));
who.append(nm);
const subs = subsOf(pkg);
const sub = el("div", "sub");
@@ -756,7 +777,8 @@
} else sub.textContent = e.providers.length ? t("Signs in to {names}", { names: e.providers.join(t(", ")) }) : t("Signs in to nothing magpie can use");
if (sub.textContent) who.append(sub);
if (mw && !e.off && !ask) who.append(mwLine(mw));
- r.append(logo(l?.icon || subs[0]?.icon), who);
+ if (mw && !e.off && !ask && editing?.pkg === pkg) who.append(optionsEditor(e));
+ r.append(logo(l?.icon || subs[0]?.icon, false, e.middlewareOnly), who);
const val = el("div", "val");
const b = busy.get(pkg) || busy.get(e.spec);
if (e.latest && e.version && newer(e.latest, e.version) && !e.off) {
@@ -808,6 +830,18 @@
r.append(val);
return r;
}
+ if (mw && !e.off && !mw.error && editing?.pkg !== pkg) {
+ const o = el("button", "text", t("Options"));
+ o.title = e.options ? t("The options it runs with") : t("It runs with no options; its package suggests some");
+ o.disabled = !!b;
+ o.onclick = () => {
+ editing = { pkg, text: JSON.stringify(e.options || e.optionsExample || {}, null, 2), error: "" };
+ draw();
+ // the field the click opened takes the keys, where it is
+ page.querySelector(".pm-opts textarea")?.focus({ preventScroll: true });
+ };
+ val.append(o);
+ }
const onoff = el("button", "text", b === "off" && !e.off && moved.length ? t("Moving back…") : t(e.off ? "Switch on" : "Switch off"));
onoff.disabled = !!b;
onoff.onclick = () => { if (!e.off && moved.length) { asking = { pkg, op: "off" }; draw(); } else off(); };
@@ -817,10 +851,50 @@
rm.onclick = () => { if (moved.length) { asking = { pkg, op: "remove" }; draw(); } else remove(); };
val.append(onoff, rm);
r.append(val);
- r.onclick = (ev) => { if (!ev.target.closest("button")) detail(l || (isGit(e.spec) ? { package: e.spec, name: e.package || e.spec, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: label(e.spec), npm: { version: e.latest } })); };
+ r.onclick = (ev) => { if (!ev.target.closest("button, .pm-opts")) detail(l || (isGit(e.spec) ? { package: e.spec, name: e.package || e.spec, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: label(e.spec), npm: { version: e.latest } })); };
return r;
}
+ // optionsEditor is a middleware's options as JSON, in its row: what it
+ // runs with, or what its package suggests when it has none. The draft is
+ // kept in editing, so a redraw while typing doesn't lose it.
+ function optionsEditor(e) {
+ const box = el("div", "pm-opts");
+ const ta = document.createElement("textarea");
+ ta.spellcheck = false;
+ ta.value = editing.text;
+ ta.rows = Math.min(14, Math.max(4, editing.text.split("\n").length + 1));
+ ta.oninput = () => { editing.text = ta.value; };
+ ta.onkeydown = (ev) => {
+ if (ev.key === "Escape") { editing = null; draw(); }
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); save(); }
+ };
+ const err = el("div", "pm-opts-err", editing.error);
+ const bar = el("div", "pm-opts-bar");
+ const note = el("span", "", e.options ? t("Applied to the next request") : t("Not set: these are its package's example"));
+ const save = () => {
+ let v;
+ try { v = JSON.parse(editing.text || "{}"); } catch (x) { editing.error = t("Not JSON: {error}", { error: x.message }); return draw(); }
+ if (!v || typeof v !== "object" || Array.isArray(v)) { editing.error = t("Options are a JSON object"); return draw(); }
+ editing = null;
+ act(name(e.spec), "options", { spec: e.spec, options: v }, () => status(t("{name}'s options saved", { name: shownName(e) }), "ok"));
+ };
+ const ok = el("button", "text primary", t("Save"));
+ ok.onclick = save;
+ const no = el("button", "text", t("Cancel"));
+ no.onclick = () => { editing = null; draw(); };
+ bar.append(note);
+ if (e.options) {
+ const clr = el("button", "text quiet", t("Clear"));
+ clr.title = t("Runs it with no options");
+ clr.onclick = () => { editing = null; act(name(e.spec), "options", { spec: e.spec, options: null }, () => status(t("{name}'s options cleared", { name: shownName(e) }), "ok")); };
+ bar.append(clr);
+ }
+ bar.append(no, ok);
+ box.append(ta, err, bar);
+ return box;
+ }
+
// mwLine is a middleware's line in its plugin's row: its hooks, how many
// calls and how long each took, and the calls that failed (which went on
// as if it weren't there), or why it didn't load
@@ -852,7 +926,7 @@
const local = isPath(l.package) || isGit(l.package);
const ed = el("div", "editor pm-detail");
const hd = el("div", "ehead pm-dhead");
- hd.append(logo(l.icon, true));
+ hd.append(logo(l.icon, true, l.kind === "middleware"));
const who = el("div", "pm-who");
const nm = el("div", "pm-name");
nm.append(el("b", "", l.name));
@@ -903,7 +977,7 @@
main.append(readme);
ed.append(main);
const bar = el("div", "bar");
- bar.append(el("span", "note", t("Plugins are other people's code: they sign in and make the requests.")), el("span", "grow"));
+ bar.append(el("span", "note", t("Plugins are other people's code: they sign in and make the requests, or see them as middleware.")), el("span", "grow"));
const close = el("button", "text", t("Close"));
close.onclick = () => { stop(); closeModal(); };
bar.append(close);
diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go
index d700ba25..36848f91 100644
--- a/internal/gui/plugins.go
+++ b/internal/gui/plugins.go
@@ -84,6 +84,14 @@ type pluginEntryJSON struct {
// rather than the host: its hooks, how often they ran and how long
// they took, and why it didn't load
Middleware *middleware.State `json:"middleware,omitempty"`
+ // OptionsExample is what its package suggests for its options, which
+ // the options editor starts from when none are set
+ OptionsExample map[string]any `json:"optionsExample,omitempty"`
+ // IsMiddleware is whether its package has gateway middleware, said of
+ // one switched off too, which Middleware leaves out; MiddlewareOnly is
+ // whether that is all it has, no provider to sign in to
+ IsMiddleware bool `json:"isMiddleware,omitempty"`
+ MiddlewareOnly bool `json:"middlewareOnly,omitempty"`
}
// autoUpdatedFor is how long a plugin's row says magpie updated it.
@@ -144,6 +152,10 @@ func pluginsState(ctx context.Context, w Windows) pluginsJSON {
if m, ok := mws[e.Spec]; ok {
j.Middleware = &m
}
+ if file, only := plugin.Middleware(plugin.Target(e.Spec)); file != "" {
+ j.IsMiddleware, j.MiddlewareOnly = true, only
+ j.OptionsExample = plugin.OptionsExample(plugin.Target(e.Spec))
+ }
j.Moved = provider.MovedOnto(e.Spec)
if j.Moved == nil {
j.Moved = []string{}
@@ -312,8 +324,9 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
// 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
+ Spec string
+ Off bool
+ Options map[string]any
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil && err != io.EOF {
fail(rw, err)
@@ -334,6 +347,8 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
err = plugin.Upgrade(ctx, plugin.Name(in.Spec))
case "off":
err = provider.SetPluginOff(ctx, in.Spec, in.Off)
+ case "options":
+ err = plugin.SetOptions(in.Spec, in.Options)
default:
http.NotFound(rw, r)
return
diff --git a/internal/gui/tests/plugin-market.test.cjs b/internal/gui/tests/plugin-market.test.cjs
index 63a7b8cd..1c792c84 100644
--- a/internal/gui/tests/plugin-market.test.cjs
+++ b/internal/gui/tests/plugin-market.test.cjs
@@ -96,10 +96,10 @@ function server(lang, asked) {
}
const L = {
- en: { tab: "Plugins", ours: "magpie community", install: "Install", soon: "Coming soon", signIn: "Sign in", onNpm: "On npm",
+ en: { tab: "Plugins", ours: "Subscriptions", install: "Install", soon: "Coming soon", signIn: "Sign in", onNpm: "On npm",
installed: "Installed", failed: /Didn't load: Cannot find module/, update: "Update", remove: "Remove", moreTile: "More in Plugins",
lookFor: "look for a plugin", week: "48k/week", readme: "Install", signing: "GitHub Copilot" },
- zh: { tab: "插件", ours: "magpie 社区", install: "安装", soon: "即将上线", signIn: "登录", onNpm: "npm 上的插件",
+ zh: { tab: "插件", ours: "订阅", install: "安装", soon: "即将上线", signIn: "登录", onNpm: "npm 上的插件",
installed: "已安装", failed: /没有加载成功:Cannot find module/, update: "更新", remove: "移除", moreTile: "插件中还有更多",
lookFor: "找找插件", week: "48k/周", readme: "Install", signing: "GitHub Copilot" },
};
diff --git a/internal/gui/tests/plugin-middleware.test.cjs b/internal/gui/tests/plugin-middleware.test.cjs
index 878b80d3..04b14cec 100644
--- a/internal/gui/tests/plugin-middleware.test.cjs
+++ b/internal/gui/tests/plugin-middleware.test.cjs
@@ -16,10 +16,10 @@ const assets = path.resolve(__dirname, "../assets");
const ALIAS = "/Users/x/mw/alias.middleware.js", BROKEN = "/Users/x/mw/broken.middleware.js", OFF = "/Users/x/mw/off.middleware.js", BOTH = "/Users/x/mw/both";
const state = { bun: true, bunVersion: "1.3.0", plugins: [
- { spec: ALIAS, providers: [], moved: [], middleware: { hooks: ["onRequest", "onEvent"], events: ["content_block_delta"], calls: 12345, avgMicros: 0.93, failures: 2, lastError: "onEvent: TypeError: x is undefined" } },
- { spec: BROKEN, providers: [], moved: [], middleware: { hooks: [], error: "exports none of onRequest, onEvent and onResponse", calls: 0, avgMicros: 0, failures: 0 } },
- { spec: OFF, off: true, providers: [], moved: [] },
- { spec: BOTH, providers: ["Acme"], moved: [], middleware: { hooks: ["onResponse"], calls: 0, avgMicros: 0, failures: 0 } },
+ { spec: ALIAS, providers: [], moved: [], isMiddleware: true, middlewareOnly: true, optionsExample: { mapping: { fast: "deepseek-chat" } }, middleware: { hooks: ["onRequest", "onEvent"], events: ["content_block_delta"], calls: 12345, avgMicros: 0.93, failures: 2, lastError: "onEvent: TypeError: x is undefined" } },
+ { spec: BROKEN, providers: [], moved: [], isMiddleware: true, middlewareOnly: true, middleware: { hooks: [], error: "exports none of onRequest, onEvent and onResponse", calls: 0, avgMicros: 0, failures: 0 } },
+ { spec: OFF, off: true, providers: [], moved: [], isMiddleware: true, middlewareOnly: true },
+ { spec: BOTH, providers: ["Acme"], moved: [], isMiddleware: true, middleware: { hooks: ["onResponse"], calls: 0, avgMicros: 0, failures: 0 } },
] };
function server(lang) {
@@ -41,8 +41,8 @@ function server(lang) {
}
const L = {
- en: { installed: "Installed", chip: "Middleware", line: "Gateway middleware: onRequest, onEvent · 12,345 calls, 0.9 µs each", failed: "2 failed", last: /Last: onEvent: TypeError: x is undefined/, events: /onEvent sees only content_block_delta events/, broken: /^Middleware didn't load: exports none/, off: "Off", nothing: "Signs in to nothing", both: "Signs in to Acme", bothLine: "Gateway middleware: onResponse" },
- zh: { installed: "已安装", chip: "中间件", line: "网关中间件:onRequest、onEvent · 调用 12,345 次,平均每次 0.9 µs", failed: "2 次失败", last: /最近一次:onEvent: TypeError: x is undefined/, events: /onEvent 只处理 content_block_delta 事件/, broken: /^中间件没有加载:exports none/, off: "关闭", nothing: "不能登录", both: "Acme", bothLine: "网关中间件:onResponse" },
+ en: { installed: "Installed", chip: "Middleware", provider: "Provider", line: "Gateway middleware: onRequest, onEvent · 12,345 calls, 0.9 µs each", failed: "2 failed", last: /Last: onEvent: TypeError: x is undefined/, events: /onEvent sees only content_block_delta events/, broken: /^Middleware didn't load: exports none/, off: "Off", nothing: "Signs in to nothing", both: "Signs in to Acme", bothLine: "Gateway middleware: onResponse" },
+ zh: { installed: "已安装", chip: "中间件", provider: "供应商", line: "网关中间件:onRequest、onEvent · 调用 12,345 次,平均每次 0.9 µs", failed: "2 次失败", last: /最近一次:onEvent: TypeError: x is undefined/, events: /onEvent 只处理 content_block_delta 事件/, broken: /^中间件没有加载:exports none/, off: "关闭", nothing: "不能登录", both: "Acme", bothLine: "网关中间件:onResponse" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
@@ -64,7 +64,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const alias = row("alias.middleware.js");
await alias.waitFor();
- assert.equal((await alias.locator(".pm-chip").innerText()).trim(), w.chip);
+ const kinds = (r) => r.locator(".pm-chip.kind").allInnerTexts().then((a) => a.map((x) => x.trim()));
+ assert.deepEqual(await kinds(alias), [w.chip]);
const line = alias.locator(".pm-mw");
assert.equal((await line.innerText()).replace(/\s+/g, " ").trim(), w.line + " · " + w.failed);
assert.match(await line.getAttribute("title"), w.events);
@@ -75,15 +76,17 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const broken = row("broken.middleware.js");
assert.match((await broken.locator(".pm-mw").innerText()).trim(), w.broken);
assert.ok(await broken.locator(".pm-mw").evaluate((e) => e.classList.contains("bad")));
- assert.equal(await broken.locator(".pm-chip").count(), 0, "no Middleware chip on one that didn't load");
+ assert.deepEqual(await kinds(broken), [w.chip], "one that didn't load is middleware still");
const off = row("off.middleware.js");
assert.equal((await off.locator(".who .sub").innerText()).trim(), w.off);
assert.equal(await off.locator(".pm-mw").count(), 0);
+ assert.deepEqual(await kinds(off), [w.chip], "one switched off is middleware still, not a provider");
const both = row("both");
assert.ok((await both.locator(".who .sub").first().innerText()).includes(w.both));
assert.equal((await both.locator(".pm-mw").innerText()).trim(), w.bothLine);
+ assert.deepEqual(await kinds(both), [w.chip, w.provider]);
// nothing drawn with a coloured left border
const borders = await view.locator(".pm-row *").evaluateAll((els) => els.filter((e) => { const s = getComputedStyle(e); return parseFloat(s.borderLeftWidth) > 0 && s.borderLeftStyle !== "none" && parseFloat(s.borderRightWidth) === 0; }).length);
@@ -93,4 +96,130 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
});
}
});
+
+ // Discover says plugins can be middleware too, not only subscriptions,
+ // and links to how one is written in the reader's language
+ test(engine + ": Discover's intro names middleware", async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ const I = {
+ en: { h: "Subscriptions and gateway middleware", p: /middleware in magpie's gateway/, a: "Write a middleware", url: "https://usemagpie.ai/docs/plugins#middleware", trust: /sign-in or your requests/ },
+ zh: { h: "订阅与网关中间件", p: /magpie 网关里的中间件/, a: "编写中间件", url: "https://usemagpie.ai/docs/zh/plugins#middleware", trust: /登录或看到你的请求/ },
+ };
+ for (const lang of ["en", "zh"]) {
+ await t.test(lang, async () => {
+ const w = I[lang];
+ const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage();
+ page.setDefaultTimeout(5000);
+ const errors = [], opened = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang));
+ await page.route("**/api/open", (r) => { opened.push(r.request().postDataJSON()); return r.fulfill({ json: {} }); });
+ await page.goto("http://magpie.test/?view=plugins");
+ const intro = page.locator("#view-plugins .pm-intro");
+ await intro.waitFor();
+ assert.equal((await intro.locator("h2").innerText()).trim(), w.h);
+ assert.match(await intro.innerText(), w.p);
+ assert.match(await intro.locator(".pm-trust").innerText(), w.trust);
+ const y = await page.evaluate(() => [document.scrollingElement.scrollTop, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join());
+ await intro.locator("a.pm-link", { hasText: w.a }).click();
+ await page.waitForTimeout(100);
+ assert.deepEqual(opened, [{ url: w.url }]);
+ assert.equal(await page.evaluate(() => [document.scrollingElement.scrollTop, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join()), y, "the click scrolled nothing");
+ if (process.env.ARTIFACT_DIR) await intro.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-intro-${engine}-${lang}.png`) });
+ assert.deepEqual(errors, []);
+ });
+ }
+ });
+
+ // a middleware's options are edited as JSON in its row: what its package
+ // suggests when none are set, JSON that isn't an object said so without
+ // a post, and Save posts the object for its spec
+ test(engine + ": a middleware's options in its row", async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ const O = {
+ en: { installed: "Installed", options: "Options", save: "Save", note: "Not set: these are its package's example", bad: /^Not JSON: /, obj: "Options are a JSON object" },
+ zh: { installed: "已安装", options: "选项", save: "保存", note: "未设置:这是包里给的示例", bad: /^不是 JSON:/, obj: "选项须是一个 JSON 对象" },
+ };
+ for (const lang of ["en", "zh"]) {
+ await t.test(lang, async () => {
+ const w = O[lang];
+ const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage();
+ page.setDefaultTimeout(5000);
+ const errors = [], posted = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang));
+ await page.route("**/api/plugins/options", (r) => { posted.push(r.request().postDataJSON()); return r.fulfill({ json: state }); });
+ await page.goto("http://magpie.test/?view=plugins");
+ const view = page.locator("#view-plugins");
+ await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
+ const row = (name) => view.locator(".pm-row").filter({ has: page.locator(".name", { hasText: name }) });
+ const alias = row("alias.middleware.js");
+ await alias.waitFor();
+ assert.equal(await row("broken.middleware.js").locator("button", { hasText: w.options }).count(), 0, "no Options on one that didn't load");
+ assert.equal(await row("off.middleware.js").locator("button", { hasText: w.options }).count(), 0, "no Options on one switched off");
+ const y = await page.evaluate(() => [document.scrollingElement.scrollTop, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join());
+ await alias.locator("button", { hasText: w.options }).click();
+ const ta = alias.locator(".pm-opts textarea");
+ await ta.waitFor();
+ assert.equal(await page.evaluate(() => [document.scrollingElement.scrollTop, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join()), y, "the click scrolled nothing");
+ assert.deepEqual(JSON.parse(await ta.inputValue()), { mapping: { fast: "deepseek-chat" } });
+ assert.equal((await alias.locator(".pm-opts-bar > span").innerText()).trim(), w.note);
+ assert.equal(await view.locator("select").count(), 0);
+ await ta.fill("{ mapping: ");
+ await alias.locator(".pm-opts button", { hasText: w.save }).click();
+ assert.match((await alias.locator(".pm-opts-err").innerText()).trim(), w.bad);
+ await alias.locator(".pm-opts textarea").fill("[1]");
+ await alias.locator(".pm-opts button", { hasText: w.save }).click();
+ assert.equal((await alias.locator(".pm-opts-err").innerText()).trim(), w.obj);
+ assert.deepEqual(posted, []);
+ await alias.locator(".pm-opts textarea").fill('{"mapping": {"fast": "glm-5"}}');
+ await alias.locator(".pm-opts button", { hasText: w.save }).click();
+ await alias.locator(".pm-opts").waitFor({ state: "detached" });
+ assert.deepEqual(posted, [{ spec: ALIAS, options: { mapping: { fast: "glm-5" } } }]);
+ const borders = await view.locator(".pm-row *").evaluateAll((els) => els.filter((e) => { const s = getComputedStyle(e); return parseFloat(s.borderLeftWidth) > 0 && s.borderLeftStyle !== "none" && parseFloat(s.borderRightWidth) === 0; }).length);
+ assert.equal(borders, 0);
+ assert.deepEqual(errors, []);
+ });
+ }
+ });
+
+ // Discover keeps subscriptions and middleware apart, and each card says
+ // which it is
+ test(engine + ": Discover's cards say provider or middleware", async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ const listings = [
+ { package: "@magpie-community/opencode-zed-auth", name: "Zed", providers: ["zed"], community: true, summary: { en: "Zed's models.", zh: "Zed 的模型。" }, npm: { version: "0.1.0" } },
+ { package: "@magpie-community/middleware-model-map", name: "Model map", kind: "middleware", community: true, summary: { en: "Model redirection.", zh: "模型重定向。" }, npm: { version: "0.1.0" } },
+ ];
+ const D = {
+ en: { subs: "Subscriptions", mw: "Gateway middleware", pv: "Provider", mwc: "Middleware" },
+ zh: { subs: "订阅", mw: "网关中间件", pv: "供应商", mwc: "中间件" },
+ };
+ for (const lang of ["en", "zh"]) {
+ await t.test(lang, async () => {
+ const w = D[lang];
+ const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage();
+ page.setDefaultTimeout(5000);
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang));
+ await page.route("**/api/plugins/listings", (r) => r.fulfill({ json: { listings } }));
+ await page.route("**/api/plugins/market", (r) => r.fulfill({ json: { listings, state } }));
+ await page.goto("http://magpie.test/?view=plugins");
+ const secs = page.locator("#view-plugins .pm-sec");
+ await secs.first().waitFor();
+ const heads = (await secs.locator(".pm-sechead h3").allInnerTexts()).map((x) => x.trim());
+ assert.deepEqual(heads, [w.subs, w.mw]);
+ const card = (n) => page.locator("#view-plugins .pm-card").filter({ hasText: n });
+ assert.equal((await card("Zed").locator(".pm-chip.kind").innerText()).trim(), w.pv);
+ assert.equal((await card("Model map").locator(".pm-chip.kind").innerText()).trim(), w.mwc);
+ assert.equal(await secs.nth(1).locator(".pm-card").count(), 1);
+ if (process.env.ARTIFACT_DIR) await page.locator("#view-plugins").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-kinds-${engine}-${lang}.png`) });
+ assert.deepEqual(errors, []);
+ });
+ }
+ });
}
diff --git a/internal/plugin/market.go b/internal/plugin/market.go
index 7d0157e2..90a85acf 100644
--- a/internal/plugin/market.go
+++ b/internal/plugin/market.go
@@ -40,6 +40,7 @@ type Listing struct {
Providers []string `json:"providers,omitempty"` // OpenCode's ids of those it signs in to
Community bool `json:"community,omitempty"` // written by magpie's community
Replaces string `json:"replaces,omitempty"` // the built-in subscription it does the work of
+ Kind string `json:"kind,omitempty"` // "middleware" for gateway middleware; none for a provider
Summary map[string]string `json:"summary,omitempty"` // by language: en, zh
}
diff --git a/internal/plugin/market.json b/internal/plugin/market.json
index d3d6fa10..d619d363 100644
--- a/internal/plugin/market.json
+++ b/internal/plugin/market.json
@@ -22,6 +22,16 @@
{ "package": "@magpie-community/opencode-kiro-auth", "name": "Kiro", "icon": "kiro-color", "providers": ["kiro"], "community": true, "replaces": "kiro",
"summary": { "en": "Kiro Free, Pro, Pro+ and Power — Kiro's sign-in, kiro-cli's, or an API key.", "zh": "Kiro Free、Pro、Pro+ 与 Power:Kiro 登录、沿用 kiro-cli 的登录,或 API key。" } },
{ "package": "@magpie-community/opencode-workbuddy-auth", "name": "WorkBuddy", "icon": "workbuddy-color", "providers": ["workbuddy", "workbuddy-ai"], "community": true, "replaces": "workbuddy",
- "summary": { "en": "WorkBuddy's China build (CodeBuddy plan) and its international build.", "zh": "WorkBuddy 国内版(CodeBuddy 套餐)与国际版。" } }
+ "summary": { "en": "WorkBuddy's China build (CodeBuddy plan) and its international build.", "zh": "WorkBuddy 国内版(CodeBuddy 套餐)与国际版。" } },
+ { "package": "@magpie-community/middleware-param-override", "name": "Param Override", "kind": "middleware", "community": true,
+ "summary": { "en": "New API's parameter override: set, delete, move or rewrite fields of every request, by model or by condition.", "zh": "New API 的参数覆盖:按模型或条件设置、删除、移动或改写每个请求的字段。" } },
+ { "package": "@magpie-community/middleware-model-map", "name": "Model Map", "kind": "middleware", "community": true,
+ "summary": { "en": "New API's model redirection: send a model under another name, by name or pattern; replies keep the name asked for.", "zh": "New API 的模型重定向:按名称或正则把模型换成另一个发出,回复里仍是原来的名字。" } },
+ { "package": "@magpie-community/middleware-system-prompt", "name": "System Prompt", "kind": "middleware", "community": true,
+ "summary": { "en": "Add your own system prompt to every request, or only some agents' or models', in each API's own place.", "zh": "给每个请求(或指定 agent、模型)加上自己的系统提示词,放在各 API 自己的位置。" } },
+ { "package": "@magpie-community/middleware-word-guard", "name": "Word Guard", "kind": "middleware", "community": true,
+ "summary": { "en": "New API's sensitive-word filter: turn away or mask words and patterns in what you send, and in replies.", "zh": "New API 的敏感词过滤:拒绝或替换你发出的内容和回复里的词与正则。" } },
+ { "package": "@magpie-community/middleware-think-tags", "name": "Think Tags", "kind": "middleware", "community": true,
+ "summary": { "en": "Take a model's … out of its reply's text, or put a Chat reply's reasoning into it.", "zh": "去掉回复正文开头的 …,或把 Chat 回复的思考内容放进正文。" } }
]
}
diff --git a/internal/plugin/market_icons_test.go b/internal/plugin/market_icons_test.go
index 330253f3..19b460d5 100644
--- a/internal/plugin/market_icons_test.go
+++ b/internal/plugin/market_icons_test.go
@@ -16,6 +16,10 @@ func TestMarketIconsAreBundled(t *testing.T) {
}
names := map[string]string{}
for _, x := range l {
+ // a middleware's card may go without: it has no provider's logo
+ if x.Icon == "" && x.Kind == "middleware" {
+ continue
+ }
names[x.Icon] = x.Package
}
for k, ic := range otherIcons {
diff --git a/internal/plugin/market_test.go b/internal/plugin/market_test.go
index 76c939af..76235eb9 100644
--- a/internal/plugin/market_test.go
+++ b/internal/plugin/market_test.go
@@ -29,9 +29,17 @@ func TestBuiltinMarket(t *testing.T) {
if !x.Community {
t.Errorf("%s: only magpie's community's plugins are listed", x.Package)
}
- if len(x.Providers) == 0 || x.Icon == "" {
+ // gateway middleware signs in to nothing
+ if x.Kind == "middleware" {
+ if len(x.Providers) != 0 || x.Replaces != "" {
+ t.Errorf("%s: middleware with providers", x.Package)
+ }
+ } else if len(x.Providers) == 0 || x.Icon == "" {
t.Errorf("%s: needs providers and an icon", x.Package)
}
+ if x.Kind != "" && x.Kind != "middleware" {
+ t.Errorf("%s: kind %q", x.Package, x.Kind)
+ }
}
if len(l) < 10 {
t.Errorf("%d listed", len(l))
diff --git a/internal/plugin/middleware.go b/internal/plugin/middleware.go
index da850324..cad06814 100644
--- a/internal/plugin/middleware.go
+++ b/internal/plugin/middleware.go
@@ -43,3 +43,22 @@ func Middleware(target string) (file string, only bool) {
// ListStamp changes when plugins.json does.
func ListStamp() string { return listStamp() }
+
+// OptionsExample is the options a plugin at target suggests, from its
+// package.json's magpie.options: what its options editor starts from
+// before any are set.
+func OptionsExample(target string) map[string]any {
+ b, err := os.ReadFile(filepath.Join(target, "package.json"))
+ if err != nil {
+ return nil
+ }
+ var pkg struct {
+ Magpie struct {
+ Options map[string]any `json:"options"`
+ } `json:"magpie"`
+ }
+ if json.Unmarshal(b, &pkg) != nil {
+ return nil
+ }
+ return pkg.Magpie.Options
+}
diff --git a/internal/plugin/store.go b/internal/plugin/store.go
index 3fac74a8..8f984f56 100644
--- a/internal/plugin/store.go
+++ b/internal/plugin/store.go
@@ -452,6 +452,43 @@ func SetOff(name string, off bool) error {
return nil
}
+// SetOptions sets the options a plugin is handed (a middleware's
+// ctx.options); nil takes them away.
+func SetOptions(name string, opts map[string]any) 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 {
+ // a short name, as the community's READMEs write it: param-override
+ // for @magpie-community/middleware-param-override
+ i = slices.IndexFunc(l.Plugins, func(x Entry) bool { return ShortName(Name(x.Spec)) == name })
+ }
+ if i < 0 {
+ return fmt.Errorf("no plugin %q", name)
+ }
+ if len(opts) == 0 {
+ opts = nil
+ }
+ l.Plugins[i].Options = opts
+ if err := save(l); err != nil {
+ return err
+ }
+ Restart()
+ return nil
+}
+
+// ShortName is a package's name without its scope and the words every
+// package of its kind has: model-map for @magpie-community/middleware-model-map,
+// zed for @magpie-community/opencode-zed-auth.
+func ShortName(pkg string) string {
+ if i := strings.LastIndex(pkg, "/"); i >= 0 {
+ pkg = pkg[i+1:]
+ }
+ pkg = strings.TrimPrefix(strings.TrimPrefix(pkg, "middleware-"), "opencode-")
+ return strings.TrimSuffix(pkg, "-auth")
+}
+
// SetConfig sets the OpenCode config the plugins are handed.
func SetConfig(cfg map[string]any) error {
listMu.Lock()
diff --git a/internal/plugin/store_shortname_test.go b/internal/plugin/store_shortname_test.go
new file mode 100644
index 00000000..b2a71bec
--- /dev/null
+++ b/internal/plugin/store_shortname_test.go
@@ -0,0 +1,40 @@
+package plugin
+
+import (
+ "reflect"
+ "testing"
+)
+
+// The community's READMEs say `magpie plugin options model-map ''`:
+// a package's short name finds it, as its full name and its spec do.
+func TestSetOptionsShortName(t *testing.T) {
+ storeSandbox(t)
+ writeList(t, `{"plugins":[{"spec":"@magpie-community/opencode-zed-auth"},{"spec":"@magpie-community/middleware-model-map@0.1.0"}]}`)
+ want := map[string]any{"mapping": map[string]any{"fast": "deepseek-chat"}}
+ if err := SetOptions("model-map", want); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetOptions("zed", map[string]any{"x": true}); err != nil {
+ t.Fatal(err)
+ }
+ l := Load()
+ if !reflect.DeepEqual(l.Plugins[1].Options, want) {
+ t.Errorf("model-map's options = %v", l.Plugins[1].Options)
+ }
+ if l.Plugins[0].Options["x"] != true {
+ t.Errorf("zed's options = %v", l.Plugins[0].Options)
+ }
+ if err := SetOptions("map", want); err == nil {
+ t.Error("a part of a name found a plugin")
+ }
+ for in, out := range map[string]string{
+ "@magpie-community/middleware-param-override": "param-override",
+ "@magpie-community/opencode-cursor-auth": "cursor",
+ "opencode-foo": "foo",
+ "/Users/me/plugins/think-tags": "think-tags",
+ } {
+ if got := ShortName(in); got != out {
+ t.Errorf("ShortName(%q) = %q, want %q", in, got, out)
+ }
+ }
+}
diff --git a/plugin_middleware_cli_test.go b/plugin_middleware_cli_test.go
new file mode 100644
index 00000000..5ba39472
--- /dev/null
+++ b/plugin_middleware_cli_test.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/middleware"
+ "github.com/yetone/magpie/internal/plugin"
+)
+
+// magpie plugin says which plugins are gateway middleware and their hooks,
+// or why one didn't load; a middleware plugin has no provider to sign in
+// to, so the list had nothing under it.
+func TestPluginListSaysMiddleware(t *testing.T) {
+ groupsHome(t)
+ dir := t.TempDir()
+ good := filepath.Join(dir, "alias.middleware.js")
+ bad := filepath.Join(dir, "bad.middleware.js")
+ os.WriteFile(good, []byte("export const events = [\"message_start\"]\nexport function onRequest(b, ctx) {}\nexport function onEvent(e, ctx) {}\n"), 0o644)
+ os.WriteFile(bad, []byte("export function onRequest( {\n"), 0o644)
+ for _, spec := range []string{good, bad} {
+ if _, err := plugin.Add(context.Background(), spec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ middleware.Reload()
+ out, err := stdoutOf(t, func() error { return listPlugins(context.Background(), false) })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "gateway middleware onRequest, onEvent") {
+ t.Errorf("the middleware's hooks aren't listed:\n%s", out)
+ }
+ if !strings.Contains(out, "didn't load: ") || !strings.Contains(out, "SyntaxError") {
+ t.Errorf("the broken middleware doesn't say why:\n%s", out)
+ }
+ js, _ := stdoutOf(t, func() error { return listPlugins(context.Background(), true) })
+ if !strings.Contains(js, `"middleware"`) || !strings.Contains(js, `"message_start"`) {
+ t.Errorf("--json has no middleware:\n%s", js)
+ }
+}
diff --git a/plugins_cli.go b/plugins_cli.go
index cad1f6f7..898dd032 100644
--- a/plugins_cli.go
+++ b/plugins_cli.go
@@ -9,15 +9,17 @@ import (
"strings"
"time"
+ "github.com/yetone/magpie/internal/middleware"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
const pluginUsage = `usage: magpie plugin [list] [--json]
- magpie plugin add install an OpenCode provider plugin or a pi package (opencode-gemini-auth, pi-antigravity, github:owner/repo, ./my-plugin.js)
+ magpie plugin add install an OpenCode provider plugin, a pi package or a gateway middleware (opencode-gemini-auth, pi-antigravity, github:owner/repo, ./my-plugin.js, ./alias.middleware.js)
magpie plugin rm remove one
magpie plugin update install the newest version of each
magpie plugin on|off turn one on or off
+ magpie plugin options [ | off] show or set what a plugin is handed (a middleware's ctx.options)
magpie plugin login [] sign in to a provider a plugin adds
magpie plugin logout forget the sign-in
magpie plugin move|migrate run a built-in subscription's accounts on its community plugin
@@ -25,7 +27,7 @@ const pluginUsage = `usage: magpie plugin [list] [--json]
// pluginCmd: `magpie plugin …` — OpenCode's provider plugins and pi's
// packages, which sign in to a subscription and carry its requests
-// (internal/plugin).
+// (internal/plugin), and gateway middleware (internal/middleware).
func pluginCmd(args []string) error {
sub := "list"
if len(args) > 1 {
@@ -88,6 +90,11 @@ func pluginCmd(args []string) error {
}
fmt.Println(green.Render("✓"), rest[0], "is", sub)
return nil
+ case "options", "config":
+ if len(rest) < 1 || len(rest) > 2 {
+ return errors.New(pluginUsage)
+ }
+ return pluginOptions(rest[0], rest[1:])
case "login", "signin":
if len(rest) < 1 || len(rest) > 2 {
return errors.New(pluginUsage)
@@ -160,8 +167,9 @@ func listPlugins(ctx context.Context, asJSON bool) error {
errs[p.Spec] = p.Error
}
ps, perr := plugin.Providers(ctx)
+ mws := middleware.States()
if asJSON {
- b, _ := json.MarshalIndent(map[string]any{"plugins": l.Plugins, "loaded": loaded, "providers": ps}, "", " ")
+ b, _ := json.MarshalIndent(map[string]any{"plugins": l.Plugins, "loaded": loaded, "providers": ps, "middleware": mws}, "", " ")
fmt.Println(string(b))
return nil
}
@@ -185,6 +193,14 @@ func listPlugins(ctx context.Context, asJSON bool) error {
what += " " + muted.Render("("+strings.TrimSpace(n+" "+plugin.Installed(e.Spec))+")")
}
fmt.Printf("%s %s\n", what, state)
+ // its calls are counted in the gateway's process, not this one
+ if m, ok := mws[e.Spec]; ok && !e.Off {
+ if m.Error != "" {
+ fmt.Printf(" %s %s\n", muted.Render("gateway middleware"), amber.Render("didn't load: "+m.Error))
+ } else {
+ fmt.Printf(" %s %s\n", muted.Render("gateway middleware"), strings.Join(m.Hooks, ", "))
+ }
+ }
for _, p := range ps {
if p.Spec != e.Spec {
continue
@@ -208,6 +224,60 @@ func listPlugins(ctx context.Context, asJSON bool) error {
return perr
}
+// pluginOptions prints a plugin's options, or sets them from a JSON
+// object; off takes them away. With none set, it prints what its
+// package suggests.
+func pluginOptions(name string, set []string) error {
+ var e *plugin.Entry
+ ps := plugin.Load().Plugins
+ for i, x := range ps {
+ if plugin.Name(x.Spec) == name || x.Spec == name {
+ e = &ps[i]
+ break
+ }
+ }
+ // a short name, as the community's READMEs write it
+ for i, x := range ps {
+ if e == nil && plugin.ShortName(plugin.Name(x.Spec)) == name {
+ e = &ps[i]
+ }
+ }
+ if e == nil {
+ return fmt.Errorf("no plugin %q", name)
+ }
+ if len(set) == 0 {
+ if len(e.Options) == 0 {
+ ex := plugin.OptionsExample(plugin.Target(e.Spec))
+ if ex == nil {
+ fmt.Println(muted.Render("No options set."))
+ return nil
+ }
+ b, _ := json.MarshalIndent(ex, "", " ")
+ fmt.Println(muted.Render("No options set. Its package suggests:"))
+ fmt.Println(string(b))
+ return nil
+ }
+ b, _ := json.MarshalIndent(e.Options, "", " ")
+ fmt.Println(string(b))
+ return nil
+ }
+ var opts map[string]any
+ if set[0] != "off" {
+ if err := json.Unmarshal([]byte(set[0]), &opts); err != nil || opts == nil {
+ return errors.New(`options are a JSON object ('{"key": "value"}'), or off`)
+ }
+ }
+ if err := plugin.SetOptions(e.Spec, opts); err != nil {
+ return err
+ }
+ if opts == nil {
+ fmt.Println(green.Render("✓"), name, "has no options")
+ } else {
+ fmt.Println(green.Render("✓"), name, "options set")
+ }
+ return nil
+}
+
// pluginProvider is the plugins' provider named by OpenCode's id or
// magpie's.
func pluginProvider(ctx context.Context, name string) (plugin.Provider, error) {
diff --git a/site/public/docs/ja/plugins.html b/site/public/docs/ja/plugins.html
index 078a15de..2df4a903 100644
--- a/site/public/docs/ja/plugins.html
+++ b/site/public/docs/ja/plugins.html
@@ -579,10 +579,10 @@ m plugin logout acme
"providers": ["acme"],
"summary": { "en": "Your Acme Coding Plan.", "zh": "使用你的 Acme Coding Plan。" } }
- MAGPIE_PLUGIN_MARKET=<url> は magpie に別の一覧を参照させます(自分の一覧の確認に便利です)。off にすると内蔵のコピーだけを使います。
+ ミドルウェアのエントリには "kind": "middleware" を書き、providers は書きません。ゲートウェイミドルウェア の下に表示されます。MAGPIE_PLUGIN_MARKET=<url> は magpie に別の一覧を参照させます(自分の一覧の確認に便利です)。off にすると内蔵のコピーだけを使います。
diff --git a/site/public/docs/plugins.html b/site/public/docs/plugins.html
index a9eb26f3..17726d21 100644
--- a/site/public/docs/plugins.html
+++ b/site/public/docs/plugins.html
@@ -579,10 +579,10 @@ m plugin logout acme
"providers": ["acme"],
"summary": { "en": "Your Acme Coding Plan.", "zh": "使用你的 Acme Coding Plan。" } }
- MAGPIE_PLUGIN_MARKET=<url> points magpie at another list, which is useful to check yours, and off keeps to the built-in copy.
+ A middleware’s entry has "kind": "middleware" and no providers; it is listed under Gateway middleware. MAGPIE_PLUGIN_MARKET=<url> points magpie at another list, which is useful to check yours, and off keeps to the built-in copy.
Put it under packages/<name>, named @magpie-community/opencode-<name>-auth, with no runtime dependencies unless one is really needed.
+
Put it under packages/<name>, named @magpie-community/opencode-<name>-auth (a provider) or @magpie-community/middleware-<name> (middleware), with no runtime dependencies unless one is really needed.
Pushing a version bump to main publishes it.
These packages update by themselves in every magpie.
@@ -613,6 +613,24 @@ m plugin logout acme
magpie plugin add ./alias.middleware.js # or a package whose package.json has "magpie": { "middleware": "./mw.js" }
The community repository has middleware ready to add, most of it what New API does for its channels, taking the same JSON. They are under Plugins → Discover → Gateway middleware, and every card there says whether it is a provider or middleware.
New API’s param_override: set, delete, move, copy or rewrite fields of the request, for some models or under conditions, or turn a request away. A param_override copied from New API works unchanged.
A system prompt of yours on every request, or only some agents’ or models’, in each API’s own place. A request with its own keeps it unless override is on.
A middleware reads its settings from ctx.options, which are its entry’s options in plugins.json. Set them in Plugins → Installed with the middleware’s Options button, which edits them as JSON in its row, or with magpie plugin options <name> '<json>' (off clears them). The name can be the package’s, its spec, or its short name: model-map for @magpie-community/middleware-model-map. They apply from the next request on. A package can suggest options with "magpie": { "options": { … } } in its package.json: the editor starts from those when none are set, but a middleware runs with no options until you save some.
How it runs
Middleware isn’t run on Bun. It runs inside the gateway’s own process, in moejs, a JavaScript engine written in Go, because a reply streams thousands of events and each one waits for it. A hook on one event costs about a microsecond; a round trip to Bun would cost tens. Each middleware is compiled once, and each request gets a runtime of its own, so requests run in parallel and a request’s hooks share ctx.state.
@@ -630,7 +648,7 @@ m plugin logout acme
onResponse(body, ctx)
A reply that isn’t streamed, whole; ctx.status is its status.
The body, or nothing.
export const events = ["content_block_delta"] calls onEvent only for events of those names (Anthropic Messages and Responses name theirs). Chat Completions and Gemini events have no name, so leave events out for them. An event holds a piece of the reply, so a word can be split over two events. A data: [DONE] line and comments pass by unseen. A compressed reply passes as it is.
-
ctx has protocol (anthropic, chat, responses or gemini), model and stream as the agent asked, path, agent when magpie knows it, options (the plugin’s options in plugins.json) and state, an object of the request’s own.
+
ctx has protocol (anthropic, chat, responses or gemini), model and stream as the agent asked, path, agent when magpie knows it, options (the plugin’s options) and state, an object of the request’s own.
When a hook fails
A hook has 250 ms for a request or a whole reply and 50 ms for an event; loading has a second. A hook that throws, runs out of time or returns something else goes on as if it weren’t there: the request or the event is sent as it was. Settings → Plugins shows each middleware’s hooks, how many calls it had, the average time each took and how many failed, with the last error. The hooks see what the agent sent and gets back, before magpie’s redaction on the way out and after it on the way back. magpie’s own requests (thread titles, the router’s classifier) don’t go through middleware.
中间件不在 Bun 里运行,而是在网关自己的进程里,用 Go 写的 JavaScript 引擎 moejs 运行。原因是一个回复会流式传输成千上万个事件,每个事件都要等它处理完。在一个事件上调用一次钩子大约耗时 1 微秒,而和 Bun 往返一次要几十微秒。每个中间件只编译一次,每个请求有自己的运行时,所以请求之间可以并行,同一个请求的各个钩子共享 ctx.state。