文档 · 插件

编写插件

插件负责登录某个服务、替它发请求。magpie 把插件变成一个供应商,所有 agent 都能用,多账号、故障切换、用量这些也都有。magpie 运行的是 OpenCode 的供应商插件,为 OpenCode 写的插件不用改就能在这里用;注册了供应商的 pi 扩展包也能直接用(见 pi 扩展包)。本页讲插件作者需要知道的:magpie 调用哪些钩子、拿到返回值后做什么,以及怎么测试、发布和上架插件。

一段话说清楚。插件是一个 npm 包、一个 git 仓库或一个文件夹。它的模块导出一个 async 函数,形式是 OpenCode v1 插件。magpie 把所有插件加载进同一个 Bun 进程,也就是插件宿主。函数返回一组钩子:
  • config 声明供应商和它的默认模型。
  • auth 给出登录方式,以及一个 loader,由它的 fetch 发出每个请求。
  • provider.models 列出某个账号有哪些模型。
  • auth.usage 说明套餐用了多少。这是 magpie 自己的钩子,OpenCode 会忽略它。
  • auth.refresh 在账号的 token 过期前续期,同一时间只续一次。它也是 magpie 自己的钩子。
agent 用什么 API 发来都可以。网关把请求转成模型使用的 API(chat completions、Responses、Anthropic Messages 或 Gemini),交给插件的 fetch,再把回答流式传回去。

五分钟写一个插件

这个例子登录 Acme,一个虚构的编程套餐。Acme 用 API key 登录,接口兼容 OpenAI,并能报告每个账号的模型和用量。真实的插件主要区别在于怎么登录、怎么给请求签名。整个例子已在 magpie v0.1.676 和本地模拟的 Acme API 上跑通。

package.json
{
  "name": "opencode-acme-auth",
  "version": "0.1.0",
  "description": "OpenCode provider plugin: Acme Coding Plan",
  "type": "module",
  "main": "./index.mjs",
  "files": ["index.mjs", "README.md"],
  "keywords": ["opencode", "opencode-plugin", "acme"],
  "license": "MIT",
  "magpie": { "maxConcurrency": 4 }
}
index.mjs
const PROVIDER = "acme"
const BASE = "https://api.acme.example/v1"

export const AcmeAuthPlugin = async ({ client }) => ({
  // 供应商,以及还没人登录时它有的模型
  config: async (cfg) => {
    cfg.provider ??= {}
    cfg.provider[PROVIDER] ??= {
      name: "Acme",
      npm: "@ai-sdk/openai-compatible", // chat completions
      api: BASE,
      models: {
        "acme-coder": { name: "Acme Coder", limit: { context: 200000, output: 32000 }, tool_call: true },
      },
    }
  },

  auth: {
    provider: PROVIDER,
    methods: [{ type: "api", label: "Acme API key", placeholder: "acme-…" }],
    // 这个账号的每个请求带上什么
    async loader(getAuth) {
      const auth = await getAuth()
      if (auth?.type !== "api") return {}
      return {
        baseURL: BASE,
        async fetch(input, init) {
          const headers = new Headers(init?.headers)
          headers.set("authorization", `Bearer ${auth.key}`)
          headers.set("x-acme-client", "opencode-acme-auth/0.1.0")
          return fetch(input, { ...init, headers })
        },
      }
    },
    // magpie 自己的钩子:套餐用了多少
    async usage(getAuth) {
      const auth = await getAuth()
      const res = await fetch(`${BASE}/usage`, { headers: { authorization: `Bearer ${auth.key}` } })
      if (!res.ok) return { error: `Acme answered ${res.status}`, windows: [] }
      const u = await res.json()
      return {
        plan: u.plan,
        windows: [{ name: "5 hours", used: u.percent, resetsAt: u.resets_at, span: 5 * 3600 }],
      }
    },
  },

  // 向 Acme 查询这个账号有哪些模型
  provider: {
    id: PROVIDER,
    async models(provider, { auth } = {}) {
      if (auth?.type !== "api") return provider.models
      const res = await fetch(`${BASE}/models`, { headers: { authorization: `Bearer ${auth.key}` } })
      if (!res.ok) return provider.models // magpie 保留原来的列表
      const { data } = await res.json()
      const base = provider.models["acme-coder"]
      return Object.fromEntries(data.map((m) => [m.id, { ...base, id: m.id, name: m.name ?? m.id }]))
    },
  },
})

把文件夹加到 magpie,登录,然后试一下:

终端
magpie plugin add ./opencode-acme-auth   # 文件夹就地加载,不安装任何东西
magpie plugin login acme                 # 询问 key
magpie plugin                            # acme (Acme, 2 models)  signed in
magpie provider test acme                # ✓ chat  7 ms · acme-coder
magpie quota                             # acme · Pro · 5h 37% ↻59m

现在 agent 可以用 acme/acme-coder 访问它的模型,和其他供应商的模型一样。在应用里,打开插件,点添加插件,填入文件夹路径,再在这个供应商那一行登录。

在沙箱里试。正在写的插件不应碰到你真正在用的 magpie。给 magpie 一个单独的 HOME,做法见测试。

magpie 如何运行插件

magpie 在它的配置目录(~/.config/magpie 或 $XDG_CONFIG_HOME/magpie)里保存这些:

文件内容
plugins.json已添加的插件:{"plugins": [{"spec", "off"?, "options"?}], "config"?}。options 是插件的第二个参数,和 OpenCode 的 ["name", {…}] 一样。config 是交给插件的 OpenCode 配置。
plugins/已安装的 npm 和 git 包,是一个独立的 Bun 项目。
plugin-auth.json登录信息(权限 600),格式和 OpenCode 的 auth.json 相同,所以登录可以在两者之间迁移。
plugin-providers.json插件上次列出的供应商和模型,magpie 启动时不必再问插件。

包的结构

magpie 从哪里加载

magpie 查找插件服务端入口的位置和 OpenCode 一样,加载第一个导出了插件的文件:

  1. package.json 里的 exports["./server"],然后 main,然后 exports["."](exports 是字符串时就用它)。
  2. 否则是 index.ts、index.tsx、index.js、index.mjs 或 index.cjs。

路径指向单个文件时,就加载那个文件。

模块导出什么

所以不要从入口文件导出辅助函数。测试需要的东西放在一个对象上,社区插件的做法是 export const _internal = { … }。

插件函数

形式
async (input, options) => hooks
// input:   { client, project, directory, worktree, serverUrl, $, experimental_workspace }
// options: plugins.json 中这一项的 "options",没有则为 undefined
// hooks:   { config?, auth?, provider?, "chat.headers"? }

导出多个函数,一个插件就能提供多个供应商,每个函数的钩子互不相干。两个插件为同一个供应商提供了 auth 钩子时,后加载的生效,和 OpenCode 一样。

package.json 里 magpie 的字段

OpenCode 会忽略 magpie 对象,所以给 OpenCode 插件加上它是安全的。

字段含义
magpie.icon插件自己的图标,用在「发现」里的卡片(从 npm 读取,GitHub 上的插件从仓库读取)和「已安装」里的那一行;没有自己图标的供应商也用它:https:// 地址或 data:image/… URI,最大 1 MB,SVG、PNG 等都行。
magpie.maxConcurrency每个账号同时接多少个请求,1 到 1000 的整数。用户在供应商上设置的上限优先。

同样的字段也可以写在 auth 钩子上(auth.icon、auth.maxConcurrency),优先于 package.json。一个插件登录多个供应商时,可以给每个供应商各写一个 auth.icon,magpie.icon 留给插件本身。

供应商与模型

声明供应商

供应商的 id 就是 auth.provider。如果 models.dev 收录了这个 id,magpie 会以那里的条目为起点,和 OpenCode 一样。否则就在 config 钩子里声明供应商。用 ??=,以免覆盖用户在 plugins.json 里配置的供应商:

config 钩子
config: async (cfg) => {
  cfg.provider ??= {}
  cfg.provider["acme"] ??= {
    name: "Acme",
    npm: "@ai-sdk/anthropic",          // 模型使用的 API(见下表)
    api: "https://api.acme.example/v1", // 请求发往哪里,除非 loader 另有指定
    models: {
      "acme-large": {
        name: "Acme Large",
        id: "acme-large-2026-09",          // API 接受的 id,和键不同时填
        limit: { context: 400000, output: 64000 },
        reasoning: true,
        tool_call: true,
        modalities: { input: ["text", "image"] },
        cost: { input: 3, output: 15, cache_read: 0.3 }, // 每百万 token 的美元价格;套餐可不填
        variants: { low: {}, medium: {}, high: {} },        // 推理档位
      },
    },
  }
}

模型还可以写 provider: { npm, api },使用和供应商不同的 API 或地址;也可以写 headers、options,以及用 disabled: true 隐藏它。

自己能联网搜索的上游

如果插件转发的是 Anthropic 或 OpenAI 官方 API(它们自己执行托管的联网搜索工具),在 config 钩子里给提供商设 searches: true。之后客户端的联网搜索(Claude Code 的 WebSearch、Codex 的 web_search)会原样发到你的 fetch,只作用于该提供商走 Anthropic 或 Responses 的模型,不再经过 magpie 自己的代搜;Chat 模型不受影响。magpie 不会自动用它替其他模型搜索,但可以在设置里手动指定它为搜索模型。

决策 API(System One)

插件可以为自己的提供商提供 TypeSafe 的 System One API,这样它的模型就能当路由组的分类器。在 config 钩子里给提供商设 decide: true,并给每个只回答问题、不用来对话的模型设 decides: true(写在 config 钩子里或 models 钩子的列表上都行)。之后 magpie 会把每个问题以 POST {baseURL}/systemone 发给 loader 的 fetch,带上账号的登录信息,和其他请求一样。请求体是 {"model", "questions", "state"};回答 {"model", "answers": {"<问题>": {"choice", "score", "confidence"}}}。

标了 decides 的模型,以及名为 jev-* 的模型,会作为分类器提供,并对智能体隐藏:发给它的对话请求会被拒绝。提供商没有 decide: true 时,decides 不起作用,所有模型照常用于对话。

模型使用的 API

模型或供应商上 npm 指定的 AI SDK 包决定了 API,也就决定了你的 fetch 收到的请求格式。无论 agent 发来的是什么,magpie 的网关都已经转换好了:

npmAPI基地址后的路径
@ai-sdk/anthropic、@ai-sdk/google-vertex/anthropicAnthropic Messages/messages
@ai-sdk/openai、@ai-sdk/azureOpenAI Responses/responses
@ai-sdk/googleGemini/models/<id>:streamGenerateContent?alt=sse
@ai-sdk/github-copilotGPT-5 及之后(gpt-5-mini 除外)用 Responses,其余用 chat completions/responses 或 /chat/completions
其他(@ai-sdk/openai-compatible、xai、groq、deepseek 等)Chat completions/chat/completions

基地址按这个顺序选:

  1. loader 的 baseURL;
  2. 模型的 provider.api;
  3. 供应商的 api;
  4. AI SDK 包自己的默认地址(https://api.anthropic.com/v1、https://api.openai.com/v1 等)。

请求体里的 model 是模型的 API id:有 id 就用它,否则用键名。

每个账号的模型

模型因账号而异时,加一个 provider 钩子:

provider 钩子
provider: {
  id: "acme",
  async models(provider, { auth }) {
    // provider.models:models.dev 和 config 钩子给出的模型,以 id 为键
    // auth:这个账号的登录信息,和 plugin-auth.json 中保存的一样
    return { "acme-large": { ...provider.models["acme-large"], name: "Acme Large" } }
  },
}

连不上厂商时

钩子出现以下任一情况,magpie 都会保留原有的列表,和内置订阅拉取列表失败时一样:

如果失败的原因是登录失效,就抛出带 signIn: "expired" 的错误,这个账号会被标记为需要重新登录(见登录状态)。

登录

auth 钩子的形式是 { provider, methods, loader, usage?, refresh?, refreshLead?, icon?, maxConcurrency? }。每个 method 是用户可选的一种登录方式。在应用里它们是按钮;在终端里,magpie plugin login <id> [<method>] 会询问选哪个。

API key

api 方式
{ type: "api", label: "Acme API key", placeholder: "acme-…" }

浏览器或设备码登录

oauth 方式
{
  type: "oauth",
  label: "Sign in with Acme",
  async authorize(inputs) {
    const d = await startDeviceCode()
    return {
      url: d.verification_uri,                    // magpie 打开它
      instructions: `Enter the code ${d.user_code}`, // 并显示这段说明
      method: "auto",                              // "auto":callback() 自己完成
      async callback() {                           // "code":callback(code),用户粘贴的码
        const t = await pollForToken(d)
        if (!t) return { type: "failed", error: "The code expired" }
        return { type: "success", refresh: t.refresh_token, access: t.access_token,
                 expires: Date.now() + t.expires_in * 1000, accountId: t.email }
      },
    }
  },
}

登录前的提问

method 的 prompts 在 authorize 之前依次提问,每一项只在条件成立时出现:

prompts
prompts: [
  { type: "select", key: "region", message: "Region",
    options: [{ label: "Global", value: "global" }, { label: "China", value: "cn", hint: "acme.cn" }] },
  { type: "text", key: "team", message: "Team id", placeholder: "t-…",
    when: { key: "region", op: "eq", value: "global" },        // 或 condition: (inputs) => boolean
    validate: (v) => (/^t-/.test(v) ? undefined : "Starts with t-") },
]

type: "password" 的问题和 text 一样,只是输入时不显示:应用里是掩码输入框,终端里的 magpie plugin login 不回显。适合 token、个人访问令牌(PAT)。这是 magpie 自己的类型,OpenCode 只有 text 和 select。

账号

续期登录

从 magpie 0.1.684 起,插件可以用 auth.refresh 把 token 续期交给 magpie。这是 magpie 自己的钩子(OpenCode 会忽略它):

auth 钩子
auth: {
  provider: "acme",
  methods: [ … ],
  // 可选:在 expires 之前多久续期(毫秒),默认 5 分钟
  refreshLead: 5 * 60 * 1000,
  async refresh(auth, provider) {  // auth 是这个账号保存的登录信息
    const t = await exchange(auth.refresh)
    if (t.error === "invalid_grant")
      throw Object.assign(new Error("Acme 拒绝了登录"), { signIn: "expired" })
    return { access: t.access_token, refresh: t.refresh_token ?? auth.refresh,
             expires: Date.now() + t.expires_in * 1000 }
  },
  loader: async (getAuth) => ({ … }),  // getAuth() 拿到的已经是续期后的 token
}

请求

loader(getAuth, provider) 每个账号运行一次,账号登录信息变化后会重新运行。它返回 OpenCode 会交给 AI SDK 的内容:

字段magpie 怎么用
baseURL请求发往的地址,优先于模型和供应商的 api。
apiKey按 AI SDK 包的方式发送:Anthropic 用 x-api-key,Google 用 x-goog-api-key,其他用 Authorization: Bearer。API key 登录时默认就是保存的 key。
headers加到每个请求上。
fetch发出请求。不提供时,magpie 的宿主用标准 fetch。

你的 fetch(input, init) 收到的请求:

返回一个 Response。它的状态码、头和正文原样传回,正文逐块流式传输。如果你改写了流式正文,要去掉头里的 content-length 和 content-encoding。

失败与故障切换

chat.headers

OpenCode 的 chat.headers 钩子对每个请求都会运行。它的 input.sessionID 是 magpie 的会话 id,在同一段对话的各轮之间保持不变。可以用它填厂商的会话头或缓存头:

chat.headers
"chat.headers": async (input, output) => {
  if (input.model.providerID === "acme") output.headers["x-acme-session"] = input.sessionID
}

登录状态

厂商拒绝了某个账号的登录时,magpie 会给它打上标记,告诉用户,并且在它恢复之前不再往它发请求。默认情况下,401 打标记,成功清除标记。插件可以用 X-Magpie-Sign-In 响应头更准确地说明一个回答的含义,magpie 会在转发前去掉这个头:

值含义
expired登录被拒绝,无论状态码是什么(有些厂商为此返回 403 或 502)。
kept账号保持原样(这个 401 说的是别的问题)。
renewed登录已续期:清除标记,无论请求接下来遇到什么。

在 fetch 或 provider.models 里,抛出带 signIn: "expired" 的错误效果相同:

index.mjs
throw Object.assign(new Error("Acme refused the sign-in; sign in again"), { signIn: "expired" })

用量

auth.usage(getAuth, provider) 是 magpie 自己的钩子,说明一个账号的额度用了多少。它在账号的作用域里运行,所以它刷新的 token 会保存到这个账号。magpie 把结果显示在供应商里的账号上、magpie quota 里,以及网关的 GET /v1/magpie/quotas。所有字段都可选:

usage 返回值
{
  plan: "Pro",
  user: "ada@example.com",     // 厂商那边的账号名
  until: "2026-11-01T00:00:00Z", // 套餐到期时间
  renew: "auto",                // 或 "off"
  balance: "$12.40",
  error: "",                    // 显示在额度窗口的位置
  windows: [{
    name: "5 hours",
    used: 37,                   // 百分比;超用时大于 100
    resetsAt: "2026-10-03T18:00:00Z", // 或 resetSecs: 3600
    span: 18000,                // 窗口长度,单位秒
    display: "370 / 1000 credits",
    amount: 370, limit: 1000, unit: "credits", // 按数量计时,窗口自己的用量
    model: "opus",              // 只统计 id 含这个词的模型
    models: [], notModels: [],  // 或恰好这些 id,或除这些外的全部
    aside: false,               // true:用完也不影响账号使用
    runsOutAt: "2026-10-05T00:00:00Z", runsOut: 120, // 剩余额度中最早作废的一笔何时作废、作废多少
  }],
  resets: { count: 2, until: "…", byWindow: false, fiveHour: 1, weekly: 1 }, // 账号可用的重置次数
  signIn: "kept",               // 同 X-Magpie-Sign-In
}

时间可以是 ISO 字符串,也可以是以秒或毫秒为单位的时间戳。没有 signIn 时,提示要重新登录的 error 会标记账号,一次正常读取会清除标记。

插件能拿到什么

magpie 交给插件的是 OpenCode PluginInput 里的内容,只要它在没有 OpenCode 服务端时还有意义:

字段在 magpie 里
client.auth.set / .remove保存或删除当前作用域账号的登录信息。
client.app.log写入 magpie 的日志,格式为 plugin [level]: [service] message。
client.tui.showToast写入 magpie 的日志。
client.config.get所有插件的 config 钩子运行之后的配置。
其他 client.*返回 { data: undefined }。
directory、worktreemagpie 的配置目录,可以在这里存文件。
project{ id: "magpie", worktree }。
$Bun 的 shell。
serverUrl占位值,后面没有 OpenCode 服务端。

magpie 调用的钩子是 config、auth(它的 methods、loader 和 usage)、provider.models 和 chat.headers。插件的其他钩子(tool、event、chat.params 等)不会被调用,工具由 agent 自己运行。

供应商 id

测试

在沙箱里运行 magpie,这样你自己的 magpie、agent 和登录信息都不会被碰到:

终端
sb=$(mktemp -d)
m() { env HOME=$sb XDG_CONFIG_HOME=$sb/.config XDG_CACHE_HOME=$sb/.cache MAGPIE_ADDR=127.0.0.1:3499 magpie "$@"; }
m plugin add ./opencode-acme-auth </dev/null
m plugin --json                     # 插件列出的内容:方式、账号、模型、加载错误
m plugin login acme                 # 每种方式、每个提问
m provider test acme                # 每种 API 发一个很小的请求
m provider test acme acme-coder     # 或只测指定的模型
m quota                             # usage 钩子
m plugin logout acme

发布与上架

异步网关请求钩子

插件可以在 Agent 请求路由前等待外部 I/O,例如召回项目上下文。在 package.json 声明 "magpie": { "hooks": "./hooks.js" },或者添加 *.hooks.js / .mjs / .ts / .mts 文件(Bun 直接运行 TypeScript,无需构建)。导出具名异步 onRequest(body, ctx)。纯网关插件不需要 OpenCode 的 main/exports。

可选的异步 onResponse(reply, ctx) 在输出完成后接收尽力投递的通知,不修改回复,也不阻塞实时流。工具声明和工具循环由客户端负责。

它在 Bun 中运行,每个 Agent 请求调用一次,位于 JS 网关中间件之前,对所有供应商生效,包括 Codex 自己的 ChatGPT 登录。body 是 Agent API 请求对象;ctx 包含 protocol、model、stream、path、agent、session、cwd、query、options、signal。query 是最后一条用户文本,不含工具结果和图片;缺失身份信息为空,HTTP Authorization 不传入。

返回对象替换请求,undefined 保留此前请求。改动模型、流式、客户端工具或工具选择的钩子会被回滚,算作失败,这次请求不会调用它的 onResponse。每个钩子收到独立副本,失败或返回 undefined 时丢弃其原地修改。成功修改按启用顺序传给后续插件。每个钩子默认 1 秒;选项 gatewayTimeoutMs 可设为 100 到 10000 毫秒的整数。整条链按预算求和,限制为 2 到 10 秒,包含宿主启动及 RPC 共 11 秒。设置 → 插件提供选项编辑及调用次数、耗时、失败与最近结果。ctx.report(code, bytes) 只接受 ready/empty/unbound/unauthorized/unavailable/invalid/skipped 和字节数;不保存私有文本,重载后统计清零。fetch 应使用 ctx.signal。异常、畸形返回、超时、宿主正忙于启动时保留原请求。Bun 插件具有宿主权限,只安装可信代码。

用 magpie plugin add ./my-plugin 安装,magpie plugin options my-plugin '{"serverUrl":"http://127.0.0.1:8000"}' 设置选项。on/off 和选项变更触发宿主重载;仅修改源码需主动触发配置重载。内部标题和分类请求跳过此钩子。

网关中间件

插件还可以是网关中间件:agent 发给 magpie 网关的每个请求,在路由之前先经过它;回复在返回的路上也经过它。它可以给模型起别名、往系统提示词里加内容、拒绝请求、改写或隐藏模型说的话,或者自己统计经过的流量。它对所有供应商都起作用,不管是不是插件提供的。

alias.middleware.js
// alias.middleware.js
export const events = ["message_start"]

export function onRequest(body, ctx) {
  ctx.state.asked = body.model
  if (body.model === "fast") body.model = "deepseek/deepseek-chat"
  if ((ctx.options.blocked ?? []).includes(body.model)) return ctx.reject(403, "not this model")
  return body
}

export function onEvent(ev, ctx) {
  ev.message.model = ctx.state.asked  // the agent sees the name it asked for
  return ev
}
shell
magpie plugin add ./alias.middleware.js   # 或者 package.json 里写了 "magpie": { "middleware": "./mw.js" } 的包

现成的中间件

社区仓库里有现成的中间件可以直接添加,大多是 New API 为渠道提供的功能,用的也是同样的 JSON。它们在插件 → 发现 → 网关中间件里,那里每张卡片都标明它是供应商还是中间件。

包作用
param-overrideNew API 的参数覆盖(param_override):设置、删除、移动、复制或改写请求里的字段,可以只对某些模型或在满足条件时生效,也可以拒绝请求。从 New API 复制来的 param_override 不用改。
model-mapNew API 的模型重定向(model_mapping):把一个模型换成另一个名字发出,按名字或 /正则/。回复里仍是 agent 请求的模型名。
system-prompt给每个请求(或只给某些 agent、某些模型)加上你的系统提示词,放在各个 API 自己的位置。请求自带系统提示词时保留原样,除非打开 override。
word-guardNew API 的敏感词过滤:用户发送的内容里出现所列词语或正则时拒绝或打码,也可以对回复打码。
think-tags去掉回复正文开头的 <think>…</think>,或把 Chat 回复的 reasoning_content 放进正文(New API 的 thinking_to_content)。
antigravity-cloak把 Antigravity 据以拒绝 Claude Code 和 Agent SDK 系统提示词的那几个词拆开,只改发给 Antigravity 的请求;发给别的供应商的提示词保持原样。
shell
magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'

选项

中间件从 ctx.options 读取设置,也就是 plugins.json 里它那一项的 options。可以在插件 → 已安装里点中间件的选项按钮,在它那一行里以 JSON 编辑;也可以用 magpie plugin options <名字> '<json>'(off 清除)。名字可以是包名、spec 或短名:@magpie-community/middleware-model-map 的短名是 model-map。从下一个请求起生效。包可以在 package.json 里用 "magpie": { "options": { … } } 给出建议的选项:没有设置时编辑框以它为起点,但在你保存之前,中间件运行时没有选项。

运行方式

中间件不在 Bun 里运行,而是在网关自己的进程里,用 Go 写的 JavaScript 引擎 moejs 运行。原因是一个回复会流式传输成千上万个事件,每个事件都要等它处理完。在一个事件上调用一次钩子大约耗时 1 微秒,而和 Bun 往返一次要几十微秒。每个中间件只编译一次,每个请求有自己的运行时,所以请求之间可以并行,同一个请求的各个钩子共享 ctx.state。

钩子

每个钩子是一个具名导出,或者 export default { … } 的一个成员。请求体和事件都是 agent 自己的 API 格式(ctx.protocol),不是供应商的。返回要继续发送的对象;什么都不返回,就按原样发送。

钩子拿到的内容返回
onRequest(body, ctx)路由之前的请求体:改了 model,请求就会路由到别处。请求体,或者不返回。ctx.reject(status, message) 会拒绝这个请求,并用 agent 的 API 格式返回这个错误(不给状态码时是 403)。
onProvider(body, ctx)请求即将发给某个供应商时的请求体;ctx.provider 有它的 id、name、account(订阅的种类,比如 claude 或 antigravity;API 密钥没有这一项)和 model。请求每换一个供应商尝试就再调用一次,每次拿到的都是 onRequest 处理后的请求体,所以为一个供应商做的改动不会带到别的供应商。请求体,或者不返回。这时请求已经路由好了:不能再拒绝,也不能改发别处。
onEvent(event, ctx)流式回复里的每个事件:它的 data: JSON。事件、不返回,或者 null 表示丢掉这个事件。
onResponse(body, ctx)非流式的完整回复;ctx.status 是它的状态码。回复体,或者不返回。

export const events = ["content_block_delta"] 让 onEvent 只处理这些名字的事件(Anthropic Messages 和 Responses 的事件有名字)。Chat Completions 和 Gemini 的事件没有名字,处理它们时不要写 events。一个事件只是回复的一小段,一个词可能被拆在两个事件里。data: [DONE] 行和注释直接放行,不经过钩子。压缩过的回复原样放行。

ctx 里有 protocol(anthropic、chat、responses 或 gemini)、agent 请求的 model 和 stream、path、magpie 知道时的 agent、options(这个插件的选项),以及 state:每个请求独有的对象。

钩子出错时

处理请求、一次供应商尝试或完整回复时,钩子有 250 毫秒;处理一个事件时有 50 毫秒;加载有 1 秒。钩子抛出异常、超时或返回了别的东西,都当作它不存在:请求或事件按原样发送。设置 → 插件会显示每个中间件的钩子、调用次数、平均每次耗时和失败次数,以及最近一次错误。钩子看到的是 agent 发出和收到的原始内容:发出时在 magpie 脱敏之前,返回时在还原之后。magpie 自己的请求(会话标题、路由分类器)不经过中间件。

路由与用量记录

同一个模块还可以导出 strategies 和 onUsage。这样的文件可以命名为 *.gateway.js,包里也可以写 "magpie": { "gateway": "./gw.js" }。

spread.gateway.js
// spread.gateway.js
export const strategies = {
  "fewest-tokens": (pool, seats) =>
    seats.map((_, i) => i).sort((a, b) =>
      (seats[a].resting - seats[b].resting) || (seats[a].tokens - seats[b].tokens)),
}

let slow = 0

export function onUsage(row, options) {
  if (row.ms > (options.slowMs ?? 30000)) {
    slow++
    console.log(`slow: ${row.provider} ${row.model} ${row.ms} ms (${slow} so far)`)
  }
}
shell
magpie plugin add ./spread.gateway.js
magpie group set fast routing=spread/fewest-tokens

给每个智能体的工具

插件可以用 OpenCode 的 tool 钩子提供工具,magpie 把它们提供给每个智能体,不只是 OpenCode:它们组成 magpie 自己的一个 MCP 服务器,地址是 /mcp/magpie/tools(Streamable HTTP)。把这个地址加到任何支持 MCP 服务器的智能体里,它的模型就能调用你已开启的插件提供的每个工具。每个工具名为 <插件>_<工具>,插件的短名在前,例如下面的 weather-tools_now。

index.js
import { tool } from "@opencode-ai/plugin"

export const WeatherTools = async () => ({
  tool: {
    now: tool({
      description: "某个城市现在的天气。",
      args: { city: tool.schema.string().describe("城市,例如 Berlin") },
      async execute({ city }, ctx) {
        const r = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`, { signal: ctx.abort })
        return await r.text()  // 文本;对象会以 JSON 发回
      },
    }),
  },
})
shell
magpie plugin add ./weather-tools
claude mcp add --transport http magpie-tools http://127.0.0.1:3425/mcp/magpie/tools   # Claude Code;其他智能体填同一个地址

设置 → 插件里会按这些名字列出每个插件的工具,并能复制服务器地址。args 可以是 zod 字段表(tool.schema 就是 zod)、zod 对象或 JSON Schema 对象。智能体会看到它,magpie 在 execute 运行前按它检查调用的参数;参数不对时回一个写明是哪个参数的错误。ctx 与 OpenCode 的相同,agent 是发起调用的智能体。每个工具最多运行 55 秒,到时 ctx.abort 触发。

这个服务器由内置插件 插件工具 提供。只要有已开启的插件提供工具,它就保持开启;关闭时不列出任何工具,调用会说明它已关闭。用网关密钥发起的调用计入该密钥的限额。工具和提供方插件一样,以你的权限在插件宿主里运行,结果只回给调用它的智能体。

Agent 插件

插件还可以添加一个 Agent:magpie 没有内置接入的那种。它说明这个 Agent 把模型写在哪里,以及还要写些什么才能让 Agent 走 magpie 的网关。这样它就和 magpie 内置的 Agent 一样出现在 Agent 页:在它那一行选一个 magpie 的模型,magpie 就把模型连同网关地址和这个 Agent 自己的 key 写进它的配置;关掉开关,一切恢复成用户原来的样子。下面的例子添加 Aider。

agent.js
// agent.js,由 package.json 里的 "magpie": { "agent": "./agent.js" } 指定
export const agent = {
  id: "aider", name: "Aider", bin: "aider",
  config: "~/.aider.conf.yml",  // magpie 修改的文件
  model: "model",                 // 文件里模型的键
  prefix: "openai/",              // 写在 magpie 模型 id 前面
  ua: ["aider", "litellm"],
  notice: "Aider reads its settings when it starts: restart it to use the new model.",
}

export function connect({ gateway, model }) {
  return {
    "openai-api-base": gateway.v1,
    "openai-api-key": gateway.key,
    "show-model-warnings": false,
  }
}
shell
magpie plugin add ./agent-aider   # 也可以是单个 *.agent.js 文件

agent 导出

字段含义
idAgent 页、用量和命令行里用的 id:小写字母、数字、.、_ 和 -。和 magpie 内置 Agent 相同的 id(codex、claude 等)会被拒绝:插件是添加 Agent,不是替换。
name页面上显示的名字。不写就用 id。
configmagpie 修改的文件。会展开 ~ 和 $VAR。文件不存在时会新建。
formatjson、yaml 或 env,文件名看不出格式时才需要写(.json/.jsonc、.yaml/.yml、.env)。暂不支持 TOML。
model文件里模型的键,JSON 和 YAML 用点分路径(llm.model)。模型在 Agent 自己里面选(从 connect 写入的模型里挑,比如 Raycast)时不写它:这一行就只有连接开关、没有模型选择器,而 connect(这种插件必须有)调用时不带 model。
prefix写在 magpie 模型 id 前面的内容:通过 LiteLLM 访问 OpenAI 兼容接口的 Agent 用 openai/。id 本身是网关接受的 <供应商>/<模型>。
bin、dirPATH 上的命令和配置目录,用来判断 Agent 是否已安装。
uaAgent 的 User-Agent 里的词,请求没带它的 key 时,用来在用量里认出它。
icon这一行的图标:图标名、https:// 或 data: URL。不写就用插件自己的图标,再没有就用通用图标。
notice换模型之后显示的提示,和内置 Agent 一样。
libraryAgent 从哪里读资源库给它的东西:instructions(每次对话前读的 Markdown 文件)、mcp(它的 MCP 服务器文件)和 mcpFormat(这个文件的写法跟哪个 Agent 的一样:alma, antigravity, claude, cline, codex, commandcode, copilot, crush, cursor, devin, gemini, goose, grok, hermes, kimi, omp, opencode, zed;claude 即 {"mcpServers": {…}}),以及 skills(放 SKILL.md 文件夹的目录)。每项都可省略,~ 和 $VAR 会展开:library: { instructions: "~/.toy/AGENTS.md", mcp: "~/.toy/mcp.json", mcpFormat: "claude", skills: "~/.toy/skills" }。不写的话,资源库不会给它任何东西,给它某一项时会被拒绝,并说明插件该写哪个键。

connect

connect(input) 返回要在模型旁边一起写入的其他键,按键路径给出各自的值;可以是 async。它拿到 gateway(url、v1——以 /v1 结尾的地址——和 key,即这个 Agent 自己的 key,请求会按它的 id 计入用量)、model(id、name、context、output、images、efforts,以及与 /v1/models 同名的 native_endpoints——请求不经转换直接走的网关路径,如 ["/v1/chat/completions"]——和 pricing——每 token 的美元价格,十进制字符串:prompt、completion、input_cache_read、input_cache_write;不知道的就不给,绝不是空或 0)、models(magpie 给这个 Agent 的所有模型,格式相同)和 agent(id、config)。没有 connect 的插件只写模型。magpie 的模型有变化时会再调用一次 connect,它写的模型列表因此保持最新。

magpie 怎么用它

窗口里的页面

插件可以在 magpie 的窗口里加一个自己的页面。插件打开后,侧边栏的插件分组里会出现它的标签,排在 magpie 自带页面的后面。页面就是插件文件夹里的 HTML、CSS 和 JavaScript,不需要 Bun,也不用构建。下面的例子显示今天的用量。

package.json
{
  "name": "magpie-usage-glance",
  "version": "0.1.0",
  "magpie": {
    "page": {
      "title": "Usage glance", "file": "page/index.html", "icon": "page/icon.svg",
      "titles": { "zh": "用量速览", "ja": "使用量の概要" }
    },
    "permissions": ["usage:summary"]
  }
}
page/index.html
<!doctype html>
<link rel="stylesheet" href="style.css">
<script src="_magpie/bridge.js"></script>   <!-- served beside every page -->
<script src="app.js" defer></script>
<p id="calls">…</p>
page/app.js
await magpie.ready                                  // the window's theme, painted
const s = await magpie.read("usage:summary", { period: "today" })
document.getElementById("calls").textContent = `${s.calls} calls`
const opts = await magpie.options.get()            // this plugin's own options
await magpie.options.set({ ...opts, hideCost: true })

插件打开之前,插件页面就会在它的那一行显示这个页面和它能读什么。page.file 和 page.icon 是插件里面的路径;页面的文件就是 file 所在文件夹里的文件,指向外面的路径或链接不会被提供。一个包可以同时是页面、供应商、中间件或 agent。只有页面的插件不会启动插件宿主。page.titles(以及面板的 titles)按语言代码(zh、zh-TW、ja、de)给出其他语言里的标签名,其余语言用 title。

页面没写样式的地方本来就是 magpie 的样子:bridge 会加一份样式表,管字体、颜色、标题、按钮、输入框和表格,权重为零(:where()),页面自己的任何规则都会盖过它。自己写样式时,用 --magpie-* 变量和窗口保持一致。

页面能请求什么

字段 / 调用给出什么
magpie.ready窗口回应后得到窗口的主题。在那之前页面还没和 magpie 说过话;10 秒内没说的页面会显示为没有回应。
magpie.theme, magpie.onTheme(fn){ dark, lang, tokens }。bridge 也会在 <html> 上设置 data-theme、lang、color-scheme 以及 --magpie-bg、--magpie-fg、--magpie-accent 等,每次变化都会再设一次。
magpie.options.get(), .set(o)插件自己在 plugins.json 里的选项,不能是别的插件的。set 接受一个对象,返回保存后的选项,每秒最多一次。
magpie.read(name, params)magpie.permissions 声明过的读取。其他的一律拒绝,窗口拒绝一次,magpie 再拒绝一次。usage:summary(period:today、7d 或 30d)给出调用数、错误数、token 和费用,以及前 20 个模型;providers:list 给出每个供应商的 id 和名字。没有任何读取会给出密钥、账号、登录信息或请求内容。

页面不能做什么

放进 magpie 自己页面里的面板

插件还可以往 magpie 自己画的页面里加面板。每个面板是插件文件夹里的一个小页面,桥接脚本、读取、选项和沙盒都跟页面一样。它放在一张卡片里,上面是它的标题,高度随它画出来的内容。

package.json
{
  "name": "magpie-provider-notes",
  "version": "0.1.0",
  "magpie": {
    "panels": [
      { "slot": "provider", "file": "ui/notes.html", "title": "Notes" },
      { "slot": "usage", "file": "ui/spend.html", "title": "Top models today" }
    ],
    "permissions": ["usage:summary"]
  }
}
slot面板放在哪里magpie.context
agents「Agent」页面的末尾{}
usage用量「概览」的末尾{}
settings设置里的「插件」标签页,有插件在这里放了面板时才出现{}
provider每个已保存 Provider 的编辑页{ provider: { id, name } }

一个插件最多 8 个面板。面板的高度跟随它的 <html>(最高 2400px),所以让页面自己决定高度,不要给 html 或 body 固定高度。面板出错,或 10 秒内没有应答,就在它自己的位置显示「{name} 已停止工作」和「重试」;所在的页面和旁边的面板照常运行。

替代一个内置页面

页面可以替代 magpie 自己的某个页面:在 magpie.page 里加上 "replaces",取 sessions、library、routing 或 usage 之一。它的标签就出现在内置页面原来的位置,指向那个页面的链接都会打开插件的页面。两个插件要替代同一个页面时,插件列表里排在前面的那个替代,另一个作为独立页面。关掉插件,内置页面就原样回来。

package.json
"page": { "title": "My library", "file": "page/index.html", "replaces": "library" }

magpie 菜单里的菜单项

有页面的插件可以往 magpie 行的菜单里加菜单项:供应商的(右键点它)和 Agent 的(点它的拖动柄,或右键)。它们排在 magpie 自己的菜单项之后,跟页面标题一样按窗口的语言显示。点一个菜单项会打开插件的页面,并告诉它点的是哪一项,以及那一行的 id 和名字,别的都不给。页面用 magpie.onAction 处理;页面还没注册处理函数时点的菜单项会等它注册后再送到。

package.json
"page": { "title": "Balance", "file": "page/index.html" },
"actions": [
  { "id": "check", "menu": "provider", "title": "Check balance", "titles": { "zh": "查余额" } }
]
page/app.js
magpie.onAction(({ id, provider }) => {
  // id: "check"; provider: { id, name }
});

menu 是 provider 或 agent。id 由小写字母、数字和短横线组成,最长 40 个字符。一个插件最多 8 个菜单项,没写 title 的用它页面的标题。没有页面的插件不加菜单项。「插件」页面会写出插件往哪些菜单里加了菜单项。

pi 扩展包

magpie 也运行 pi 的扩展包。用 pi.registerProvider 注册了供应商的 pi 扩展,在 magpie 里和在 pi 里一样工作,不用改任何东西,添加方式和其他插件相同(magpie plugin add pi-antigravity)。

宿主协议

写插件用不到这一节,它是给开发 magpie 本身或调试宿主的人看的。magpie 向宿主的 stdin 写入 {id, method, params},每行一条。宿主回复 {id, result} 或 {id, error: {message}}。

方法作用
init加载插件,运行它们的 config 钩子。其他调用都会等它完成。
providers每个供应商、它的登录方式、账号,以及每个账号的模型。
prompt、validate登录方式的下一个提问;校验一个回答。
authorize、callback、apiKey登录。
load某个账号的 loader 选项。
fetch一个请求。先流式发出 {id, event: "head", status, headers},然后是 {id, event: "chunk", data}(base64),最后 {id, result: null}。{method: "abort", params: {id}} 取消它。
usage、check账号的用量;像真实请求一样检查一个账号。
import、take、signOut、reload账号的迁入迁出、删除,以及重新运行它们的 loader。

宿主也会主动发送事件:log、toast、auth(登录信息已保存或刷新)和 signIn(账号的登录状态,同上)。源码见 internal/plugin/host.js。

答疑

我的 OpenCode 插件不改就能在 magpie 里用吗?

可以,只要它通过 auth、config、provider 和 chat.headers 来登录和发请求。magpie 增加的部分(auth.usage、auth.refresh、magpie.icon、maxConcurrency、free、rate、X-Magpie-Sign-In)OpenCode 都会忽略,所以一个包能同时服务两边。

我的 pi 扩展包能在 magpie 里用吗?

能,只要它通过 pi.registerProvider 登录并提供模型。magpie 只使用它注册的供应商,见 pi 扩展包。给它加上 pi-package 关键词,搜索就能找到它。

为什么我的供应商叫 …-plugin?

它的 id 在 magpie 里已经有了。见供应商 id。

我的一些辅助函数被当成插件运行了。

入口模块导出的每个函数都会被当作插件调用。只导出插件函数,或者用 export default { id, server }。

可以用 npm 依赖吗?

可以。它们会随插件一起安装,但安装脚本不会运行。Bun 的 fetch、crypto、fs 和 $ 能满足大部分需要。

插件可以调用厂商的 CLI 吗?

可以,用 Bun 的 $ 或 child_process。好几个社区插件就是通过厂商自己的 CLI 登录的。要考虑到有些机器上没装它,在错误信息里说明怎么安装。

插件能看到用户的哪些内容?

它发出的请求里的一切:对话、工具及其结果。只把它发到用户要求的地方,也不要记进日志。

怎么让 magpie 看到我的修改?

终端里的 magpie 命令每次都按文件夹的当前内容加载。在应用里,到插件把它关掉再打开。

有问题,或者有插件想分享?来 Discord 问,或在 magpie-community/plugins 开 issue。