plugin, gui, docs: Plugins says which plugins are providers and which are middleware, lists the community's ready-made middleware, and edits a middleware's options in its row (the owner) — the Plugins page still read as if every plugin were a subscription, and nothing showed what a middleware does or let you set its options without editing plugins.json. Every Discover card and Installed row now carries a Provider or Middleware chip (both for a plugin that is both), also for one that is off (isMiddleware/middlewareOnly from the server, read from its files); Discover splits into Subscriptions and Gateway middleware ("runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it"), the latter from market entries with "kind": "middleware" and no providers or icon (the built-in market.json lists the five new @magpie-community/middleware-* packages: param-override, model-map, system-prompt, word-guard, think-tags, mostly New API's channel settings with the same JSON). A middleware row has Options: a JSON editor in the row, starting from the package's magpie.options example when none are set, which validates it is an object, saves (POST /api/plugins/options) for the next request, clears, and cancels with Esc. magpie plugin options takes a short name (model-map for @magpie-community/middleware-model-map; plugin.ShortName, also in SetOptions). en/zh/ja/de. Docs: usemagpie.ai/docs/plugins (en/zh/ja) gains Ready-made middleware and Options and the registry's kind; reference.md, both READMEs and docs/subsystems/gateway-middleware.md likewise. Tested with a real DeepSeek account and real Claude Code through magpie web --gateway in a sandbox HOME, with all five installed and options set by short name: a mapped model named back, max_tokens capped, a prompt appended for claude only, word-guard's 400 and param-override's 403 in the agent's shape, <think> stripped from Chat and Responses streams and a whole Anthropic reply, reasoning_content moved into the text; /api/plugins showed the kinds and the example. TestSetOptionsShortName is new; TestBuiltinMarket checks a middleware entry has no providers or icon and no other kind. plugin-middleware.test.cjs (Chromium and WebKit, en and zh: the chips, Discover's two sections, the options editor with its errors, no select, no scroll, no left border) fails without the change; the other plugin tests and click-scroll pass in WebKit (plugin-market's section name updated; plugin-move fails only under parallel load and passes alone). go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 20:50:09 +08:00
1 parent 80dc07d021
commit 2f135ed4d5
23 files changed
+747 -57

No files matched your search

+27 -1
View File
@@ -62,7 +62,7 @@ Your Claude, ChatGPT, Copilot, Gemini or Grok sign-in becomes a provider that ev
<td valign="top">
**🧩 Plugins**<br>
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.
</td>
<td valign="top">
@@ -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 `<think>…</think>` 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
<picture>
+27 -1
View File
@@ -62,7 +62,7 @@ magpie 把这些都收到一处:
<td valign="top">
**🧩 插件**<br>
npm 上的 OpenCode 登录插件和 pi provider 包,在 magpie 里的用法和在原应用里一样。插件能登录的套餐,每个 Agent 都能用。
npm 上的 OpenCode 登录插件和 pi provider 包,在 magpie 里的用法和在原应用里一样。插件能登录的套餐,每个 Agent 都能用。插件也可以是网关中间件,读取并改写每个请求和回复。
</td>
<td valign="top">
@@ -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` | 去掉回复里的 `<think>…</think>`,或把 `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)。
## 用量和费用统计
<picture>
+20
View File
@@ -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-<name>`), 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 `<think>…</think>`, 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
+1
View File
@@ -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.
+53
View File
@@ -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-<name>`), 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
```
+57 -12
View File
@@ -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}",
+14
View File
@@ -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); }
+91 -17
View File
@@ -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);
+17 -2
View File
@@ -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
+2 -2
View File
@@ -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" },
};
+137 -8
View File
@@ -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, []);
});
}
});
}
+1
View File
@@ -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
}
+11 -1
View File
@@ -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 <think>…</think> out of its reply's text, or put a Chat reply's reasoning into it.", "zh": "去掉回复正文开头的 <think>…</think>,或把 Chat 回复的思考内容放进正文。" } }
]
}
+4
View File
@@ -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 {
+9 -1
View File
@@ -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))
+19
View File
@@ -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
}
+37
View File
@@ -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()
+40
View File
@@ -0,0 +1,40 @@
package plugin
import (
"reflect"
"testing"
)
// The community's READMEs say `magpie plugin options model-map '<json>'`:
// 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)
}
}
}
+44
View File
@@ -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)
}
}
+73 -3
View File
@@ -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 <npm | git | path> install an OpenCode provider plugin or a pi package (opencode-gemini-auth, pi-antigravity, github:owner/repo, ./my-plugin.js)
magpie plugin add <npm | git | path> 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 <name> remove one
magpie plugin update install the newest version of each
magpie plugin on|off <name> turn one on or off
magpie plugin options <name> [<json> | off] show or set what a plugin is handed (a middleware's ctx.options)
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
magpie plugin logout <provider> forget the sign-in
magpie plugin move|migrate <subscription> 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) {
+21 -3
View File
@@ -579,10 +579,10 @@ m plugin logout acme</pre>
<span class="s">"providers"</span>: [<span class="s">"acme"</span>],
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> は magpie に別の一覧を参照させます(自分の一覧の確認に便利です)。<code>off</code> にすると内蔵のコピーだけを使います。</li>
ミドルウェアのエントリには <code>"kind": "middleware"</code> を書き、<code>providers</code> は書きません。<span class="ui">ゲートウェイミドルウェア</span> の下に表示されます。<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> は magpie に別の一覧を参照させます(自分の一覧の確認に便利です)。<code>off</code> にすると内蔵のコピーだけを使います。</li>
<li><b>コミュニティのパッケージ。</b><a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a> に貢献するには:
<ul>
<li><code>packages/&lt;name&gt;</code> に置き、名前は <code>@magpie-community/opencode-&lt;name&gt;-auth</code> とします。本当に必要でない限り、ランタイムの依存は持たせないでください。</li>
<li><code>packages/&lt;name&gt;</code> に置き、名前はプロバイダなら <code>@magpie-community/opencode-&lt;name&gt;-auth</code>、ミドルウェアなら <code>@magpie-community/middleware-&lt;name&gt;</code> とします。本当に必要でない限り、ランタイムの依存は持たせないでください。</li>
<li>バージョンを上げて <code>main</code> にプッシュすると公開されます。</li>
<li>これらのパッケージは、どの magpie でも自動で更新されます。</li>
</ul></li>
@@ -613,6 +613,24 @@ m plugin logout acme</pre>
<button class="copy" data-copy="mw-cli">コピー</button>
<pre id="mw-cli">magpie plugin add ./alias.middleware.js <span class="c"># または package.json に "magpie": { "middleware": "./mw.js" } を書いたパッケージ</span></pre>
</div>
<h3 id="middleware-community"><a class="anchor" href="#middleware-community">すぐ使えるミドルウェア</a></h3>
<p>コミュニティリポジトリには、そのまま追加できるミドルウェアがあります。多くは <a href="https://github.com/QuantumNous/new-api">New API</a> がチャネルに対して行うことで、同じ JSON を受け取ります。<span class="ui">プラグイン → 発見 → ゲートウェイミドルウェア</span> にあり、そこのカードにはプロバイダかミドルウェアかが表示されます。</p>
<div class="table-wrap"><table>
<tr><th>パッケージ</th><th>すること</th></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/param-override"><code>param-override</code></a></td><td>New API の <code>param_override</code>:リクエストのフィールドを設定・削除・移動・コピー・書き換えします。特定のモデルや条件に絞ることも、リクエストを断ることもできます。New API からコピーした <code>param_override</code> はそのまま動きます。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/model-map"><code>model-map</code></a></td><td>New API の <code>model_mapping</code>:モデルを別の名前で送ります。名前か <code>/パターン/</code> で指定します。応答にはエージェントが指定したモデル名が入ります。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/system-prompt"><code>system-prompt</code></a></td><td>すべてのリクエスト(または特定のエージェントやモデルのもの)に自分のシステムプロンプトを、各 API の決まった場所に加えます。リクエストに独自のものがあれば、<code>override</code> を有効にしない限りそのままにします。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/word-guard"><code>word-guard</code></a></td><td>New API の禁止語フィルタ:ユーザーが送る内容にある語やパターンを断るか伏せ字にします。応答も伏せ字にできます。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/think-tags"><code>think-tags</code></a></td><td>応答本文の先頭にある <code>&lt;think&gt;…&lt;/think&gt;</code> を取り除くか、Chat の応答の <code>reasoning_content</code> を本文に入れます(New API の <code>thinking_to_content</code>)。</td></tr>
</table></div>
<div class="code">
<span class="lang">shell</span>
<button class="copy" data-copy="mw-add">コピー</button>
<pre id="mw-add">magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'</pre>
</div>
<h3 id="middleware-options"><a class="anchor" href="#middleware-options">オプション</a></h3>
<p>ミドルウェアは設定を <code>ctx.options</code> から読みます。これは <code>plugins.json</code> のそのエントリの <code>options</code> です。<span class="ui">プラグイン → インストール済み</span> でミドルウェアの <span class="ui">オプション</span> ボタンを押すとその行で JSON として編集でき、<code>magpie plugin options &lt;名前&gt; '&lt;json&gt;'</code> でも設定できます(<code>off</code> で消去)。名前はパッケージ名、spec、短い名前のどれでもかまいません:<code>@magpie-community/middleware-model-map</code> なら <code>model-map</code> です。次のリクエストから反映されます。パッケージは <code>package.json</code> の <code>"magpie": { "options": { … } }</code> でオプションの例を示せます。未設定のときエディタはそれから始まりますが、保存するまでミドルウェアはオプションなしで動きます。</p>
<h3>動き方</h3>
<p>ミドルウェアは Bun では動きません。ゲートウェイ自身のプロセスの中で、Go で書かれた JavaScript エンジン <a href="https://github.com/Calcium-Ion/moejs">moejs</a> によって動きます。応答は数千のイベントとしてストリームされ、各イベントがミドルウェアの処理を待つからです。1 イベントでのフック呼び出しは約 1 マイクロ秒で済みますが、Bun との往復だと数十マイクロ秒かかります。各ミドルウェアのコンパイルは一度だけで、リクエストごとに専用のランタイムを使います。そのためリクエストは並行に処理され、同じリクエストのフック同士は <code>ctx.state</code> を共有します。</p>
<ul>
@@ -630,7 +648,7 @@ m plugin logout acme</pre>
<tr><td><code>onResponse(body, ctx)</code></td><td>ストリームでない応答の全体。<code>ctx.status</code> がそのステータスです。</td><td>本文、または何も返さない。</td></tr>
</table></div>
<p><code>export const events = ["content_block_delta"]</code> と書くと、<code>onEvent</code> はその名前のイベントだけで呼ばれます(Anthropic Messages と Responses のイベントには名前があります)。Chat Completions と Gemini のイベントには名前がないので、それらでは <code>events</code> を書かないでください。1 つのイベントは応答の一部なので、1 つの単語が 2 つのイベントに分かれることがあります。<code>data: [DONE]</code> の行とコメントはフックを通らずにそのまま流れます。圧縮された応答もそのまま流れます。</p>
<p><code>ctx</code> には次のものがあります:<code>protocol</code>(<code>anthropic</code>・<code>chat</code>・<code>responses</code>・<code>gemini</code>)、エージェントが指定した <code>model</code> と <code>stream</code>、<code>path</code>、magpie が把握している場合の <code>agent</code>、<code>options</code>(<code>plugins.json</code> にあるそのプラグインのオプション)、そしてリクエストごとのオブジェクト <code>state</code>。</p>
<p><code>ctx</code> には次のものがあります:<code>protocol</code>(<code>anthropic</code>・<code>chat</code>・<code>responses</code>・<code>gemini</code>)、エージェントが指定した <code>model</code> と <code>stream</code>、<code>path</code>、magpie が把握している場合の <code>agent</code>、<code>options</code>(そのプラグインの<a href="#middleware-options">オプション</a>)、そしてリクエストごとのオブジェクト <code>state</code>。</p>
<h3>フックが失敗したとき</h3>
<p>フックの持ち時間は、リクエストまたは応答全体で 250 ms、1 イベントで 50 ms、読み込みで 1 秒です。例外を投げたフック、時間切れになったフック、想定外の値を返したフックは、なかったものとして扱われます。つまりリクエストやイベントは元のまま送られます。設定 → プラグインには、ミドルウェアごとのフック、呼び出し回数、1 回あたりの平均時間、失敗回数と直近のエラーが表示されます。フックが見るのは、エージェントが送ったものと受け取るものそのものです。送信時は magpie のマスキングより前、受信時はマスク解除の後です。magpie 自身のリクエスト(スレッドのタイトル、ルーターの分類器)はミドルウェアを通りません。</p>
+21 -3
View File
@@ -579,10 +579,10 @@ m plugin logout acme</pre>
<span class="s">"providers"</span>: [<span class="s">"acme"</span>],
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> points magpie at another list, which is useful to check yours, and <code>off</code> keeps to the built-in copy.</li>
A middleware’s entry has <code>"kind": "middleware"</code> and no <code>providers</code>; it is listed under <span class="ui">Gateway middleware</span>. <code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> points magpie at another list, which is useful to check yours, and <code>off</code> keeps to the built-in copy.</li>
<li><b>The community packages.</b> To contribute one to <a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a>:
<ul>
<li>Put it under <code>packages/&lt;name&gt;</code>, named <code>@magpie-community/opencode-&lt;name&gt;-auth</code>, with no runtime dependencies unless one is really needed.</li>
<li>Put it under <code>packages/&lt;name&gt;</code>, named <code>@magpie-community/opencode-&lt;name&gt;-auth</code> (a provider) or <code>@magpie-community/middleware-&lt;name&gt;</code> (middleware), with no runtime dependencies unless one is really needed.</li>
<li>Pushing a version bump to <code>main</code> publishes it.</li>
<li>These packages update by themselves in every magpie.</li>
</ul></li>
@@ -613,6 +613,24 @@ m plugin logout acme</pre>
<button class="copy" data-copy="mw-cli">Copy</button>
<pre id="mw-cli">magpie plugin add ./alias.middleware.js <span class="c"># or a package whose package.json has "magpie": { "middleware": "./mw.js" }</span></pre>
</div>
<h3 id="middleware-community"><a class="anchor" href="#middleware-community">Ready-made middleware</a></h3>
<p>The community repository has middleware ready to add, most of it what <a href="https://github.com/QuantumNous/new-api">New API</a> does for its channels, taking the same JSON. They are under <span class="ui">Plugins → Discover → Gateway middleware</span>, and every card there says whether it is a provider or middleware.</p>
<div class="table-wrap"><table>
<tr><th>Package</th><th>What it does</th></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/param-override"><code>param-override</code></a></td><td>New API’s <code>param_override</code>: set, delete, move, copy or rewrite fields of the request, for some models or under conditions, or turn a request away. A <code>param_override</code> copied from New API works unchanged.</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/model-map"><code>model-map</code></a></td><td>New API’s <code>model_mapping</code>: send a model under another name, by name or <code>/pattern/</code>. Replies name the model the agent asked for.</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/system-prompt"><code>system-prompt</code></a></td><td>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 <code>override</code> is on.</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/word-guard"><code>word-guard</code></a></td><td>New API’s sensitive-word filter: turn away or mask words and patterns in what users send, and in replies if you like.</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/think-tags"><code>think-tags</code></a></td><td>Take a <code>&lt;think&gt;…&lt;/think&gt;</code> block out of the start of a reply’s text, or put a Chat reply’s <code>reasoning_content</code> into it (New API’s <code>thinking_to_content</code>).</td></tr>
</table></div>
<div class="code">
<span class="lang">shell</span>
<button class="copy" data-copy="mw-add">Copy</button>
<pre id="mw-add">magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'</pre>
</div>
<h3 id="middleware-options"><a class="anchor" href="#middleware-options">Options</a></h3>
<p>A middleware reads its settings from <code>ctx.options</code>, which are its entry’s <code>options</code> in <code>plugins.json</code>. Set them in <span class="ui">Plugins → Installed</span> with the middleware’s <span class="ui">Options</span> button, which edits them as JSON in its row, or with <code>magpie plugin options &lt;name&gt; '&lt;json&gt;'</code> (<code>off</code> clears them). The name can be the package’s, its spec, or its short name: <code>model-map</code> for <code>@magpie-community/middleware-model-map</code>. They apply from the next request on. A package can suggest options with <code>"magpie": { "options": { … } }</code> in its <code>package.json</code>: the editor starts from those when none are set, but a middleware runs with no options until you save some.</p>
<h3>How it runs</h3>
<p>Middleware isn’t run on Bun. It runs inside the gateway’s own process, in <a href="https://github.com/Calcium-Ion/moejs">moejs</a>, 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 <code>ctx.state</code>.</p>
<ul>
@@ -630,7 +648,7 @@ m plugin logout acme</pre>
<tr><td><code>onResponse(body, ctx)</code></td><td>A reply that isn’t streamed, whole; <code>ctx.status</code> is its status.</td><td>The body, or nothing.</td></tr>
</table></div>
<p><code>export const events = ["content_block_delta"]</code> calls <code>onEvent</code> only for events of those names (Anthropic Messages and Responses name theirs). Chat Completions and Gemini events have no name, so leave <code>events</code> out for them. An event holds a piece of the reply, so a word can be split over two events. A <code>data: [DONE]</code> line and comments pass by unseen. A compressed reply passes as it is.</p>
<p><code>ctx</code> has <code>protocol</code> (<code>anthropic</code>, <code>chat</code>, <code>responses</code> or <code>gemini</code>), <code>model</code> and <code>stream</code> as the agent asked, <code>path</code>, <code>agent</code> when magpie knows it, <code>options</code> (the plugin’s options in <code>plugins.json</code>) and <code>state</code>, an object of the request’s own.</p>
<p><code>ctx</code> has <code>protocol</code> (<code>anthropic</code>, <code>chat</code>, <code>responses</code> or <code>gemini</code>), <code>model</code> and <code>stream</code> as the agent asked, <code>path</code>, <code>agent</code> when magpie knows it, <code>options</code> (the plugin’s <a href="#middleware-options">options</a>) and <code>state</code>, an object of the request’s own.</p>
<h3>When a hook fails</h3>
<p>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.</p>
+21 -3
View File
@@ -579,10 +579,10 @@ m plugin logout acme</pre>
<span class="s">"providers"</span>: [<span class="s">"acme"</span>],
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> 让 magpie 改用另一份列表,可以用来检查你的条目;设为 <code>off</code> 则只用内置副本。</li>
中间件的条目写 <code>"kind": "middleware"</code>,不写 <code>providers</code>,列在<span class="ui">网关中间件</span>下。<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> 让 magpie 改用另一份列表,可以用来检查你的条目;设为 <code>off</code> 则只用内置副本。</li>
<li><b>社区包。</b>想把插件贡献到 <a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a>:
<ul>
<li>放在 <code>packages/&lt;name&gt;</code> 下,包名为 <code>@magpie-community/opencode-&lt;name&gt;-auth</code>,除非确实需要,不要有运行时依赖。</li>
<li>放在 <code>packages/&lt;name&gt;</code> 下,包名为 <code>@magpie-community/opencode-&lt;name&gt;-auth</code>(供应商)或 <code>@magpie-community/middleware-&lt;name&gt;</code>(中间件),除非确实需要,不要有运行时依赖。</li>
<li>把版本号的改动推到 <code>main</code> 就会发布。</li>
<li>这些包在每个 magpie 里都会自动更新。</li>
</ul></li>
@@ -613,6 +613,24 @@ m plugin logout acme</pre>
<button class="copy" data-copy="mw-cli">复制</button>
<pre id="mw-cli">magpie plugin add ./alias.middleware.js <span class="c"># 或者 package.json 里写了 "magpie": { "middleware": "./mw.js" } 的包</span></pre>
</div>
<h3 id="middleware-community"><a class="anchor" href="#middleware-community">现成的中间件</a></h3>
<p>社区仓库里有现成的中间件可以直接添加,大多是 <a href="https://github.com/QuantumNous/new-api">New API</a> 为渠道提供的功能,用的也是同样的 JSON。它们在<span class="ui">插件 → 发现 → 网关中间件</span>里,那里每张卡片都标明它是供应商还是中间件。</p>
<div class="table-wrap"><table>
<tr><th>包</th><th>作用</th></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/param-override"><code>param-override</code></a></td><td>New API 的参数覆盖(<code>param_override</code>):设置、删除、移动、复制或改写请求里的字段,可以只对某些模型或在满足条件时生效,也可以拒绝请求。从 New API 复制来的 <code>param_override</code> 不用改。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/model-map"><code>model-map</code></a></td><td>New API 的模型重定向(<code>model_mapping</code>):把一个模型换成另一个名字发出,按名字或 <code>/正则/</code>。回复里仍是 agent 请求的模型名。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/system-prompt"><code>system-prompt</code></a></td><td>给每个请求(或只给某些 agent、某些模型)加上你的系统提示词,放在各个 API 自己的位置。请求自带系统提示词时保留原样,除非打开 <code>override</code>。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/word-guard"><code>word-guard</code></a></td><td>New API 的敏感词过滤:用户发送的内容里出现所列词语或正则时拒绝或打码,也可以对回复打码。</td></tr>
<tr><td><a href="https://github.com/magpie-community/plugins/tree/main/packages/think-tags"><code>think-tags</code></a></td><td>去掉回复正文开头的 <code>&lt;think&gt;…&lt;/think&gt;</code>,或把 Chat 回复的 <code>reasoning_content</code> 放进正文(New API 的 <code>thinking_to_content</code>)。</td></tr>
</table></div>
<div class="code">
<span class="lang">shell</span>
<button class="copy" data-copy="mw-add">复制</button>
<pre id="mw-add">magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'</pre>
</div>
<h3 id="middleware-options"><a class="anchor" href="#middleware-options">选项</a></h3>
<p>中间件从 <code>ctx.options</code> 读取设置,也就是 <code>plugins.json</code> 里它那一项的 <code>options</code>。可以在<span class="ui">插件 → 已安装</span>里点中间件的<span class="ui">选项</span>按钮,在它那一行里以 JSON 编辑;也可以用 <code>magpie plugin options &lt;名字&gt; '&lt;json&gt;'</code>(<code>off</code> 清除)。名字可以是包名、spec 或短名:<code>@magpie-community/middleware-model-map</code> 的短名是 <code>model-map</code>。从下一个请求起生效。包可以在 <code>package.json</code> 里用 <code>"magpie": { "options": { … } }</code> 给出建议的选项:没有设置时编辑框以它为起点,但在你保存之前,中间件运行时没有选项。</p>
<h3>运行方式</h3>
<p>中间件不在 Bun 里运行,而是在网关自己的进程里,用 Go 写的 JavaScript 引擎 <a href="https://github.com/Calcium-Ion/moejs">moejs</a> 运行。原因是一个回复会流式传输成千上万个事件,每个事件都要等它处理完。在一个事件上调用一次钩子大约耗时 1 微秒,而和 Bun 往返一次要几十微秒。每个中间件只编译一次,每个请求有自己的运行时,所以请求之间可以并行,同一个请求的各个钩子共享 <code>ctx.state</code>。</p>
<ul>
@@ -630,7 +648,7 @@ m plugin logout acme</pre>
<tr><td><code>onResponse(body, ctx)</code></td><td>非流式的完整回复;<code>ctx.status</code> 是它的状态码。</td><td>回复体,或者不返回。</td></tr>
</table></div>
<p><code>export const events = ["content_block_delta"]</code> 让 <code>onEvent</code> 只处理这些名字的事件(Anthropic Messages 和 Responses 的事件有名字)。Chat Completions 和 Gemini 的事件没有名字,处理它们时不要写 <code>events</code>。一个事件只是回复的一小段,一个词可能被拆在两个事件里。<code>data: [DONE]</code> 行和注释直接放行,不经过钩子。压缩过的回复原样放行。</p>
<p><code>ctx</code> 里有 <code>protocol</code>(<code>anthropic</code>、<code>chat</code>、<code>responses</code> 或 <code>gemini</code>)、agent 请求的 <code>model</code> 和 <code>stream</code>、<code>path</code>、magpie 知道时的 <code>agent</code>、<code>options</code>(<code>plugins.json</code> 里这个插件的选项),以及 <code>state</code>:每个请求独有的对象。</p>
<p><code>ctx</code> 里有 <code>protocol</code>(<code>anthropic</code>、<code>chat</code>、<code>responses</code> 或 <code>gemini</code>)、agent 请求的 <code>model</code> 和 <code>stream</code>、<code>path</code>、magpie 知道时的 <code>agent</code>、<code>options</code>(这个插件的<a href="#middleware-options">选项</a>),以及 <code>state</code>:每个请求独有的对象。</p>
<h3>钩子出错时</h3>
<p>处理请求或完整回复时,钩子有 250 毫秒;处理一个事件时有 50 毫秒;加载有 1 秒。钩子抛出异常、超时或返回了别的东西,都当作它不存在:请求或事件按原样发送。设置 → 插件会显示每个中间件的钩子、调用次数、平均每次耗时和失败次数,以及最近一次错误。钩子看到的是 agent 发出和收到的原始内容:发出时在 magpie 脱敏之前,返回时在还原之后。magpie 自己的请求(会话标题、路由分类器)不经过中间件。</p>