文档 · 插件

编写插件

插件负责登录某个服务、替它发请求。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供应商的图标:https:// 地址或 data:image/… URI,最大 1 MB,SVG、PNG 等都行。
magpie.maxConcurrency每个账号同时接多少个请求,1 到 1000 的整数。用户在供应商上设置的上限优先。

同样的字段也可以写在 auth 钩子上(auth.icon、auth.maxConcurrency),优先于 package.json。

供应商与模型

声明供应商

供应商的 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 隐藏它。

模型使用的 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-") },
]

账号

续期登录

从 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",
    model: "opus",              // 只统计 id 含这个词的模型
    models: [], notModels: [],  // 或恰好这些 id,或除这些外的全部
    aside: false,               // true:用完也不影响账号使用
  }],
  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

发布与上架

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。