# 插件系统(Plugin System)
> **English**: this document is the reference for writing ModelRouter plugins.
> The Chinese version is the primary one; section titles map 1:1.
ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
插件能做两件事:
1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
并可以把结果累加进自己的状态。
2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
新页面,也可以是往现有页面里追加一个组件。
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
---
## 1. 快速上手
一个最小的插件:
```lua
-- plugins/hello.lua
local plugin = {
name = "hello",
version = "1.0.0",
description = "示例插件",
author = "you",
}
-- 声明钩子
plugin.hooks = {
request_end = "on_request_end",
}
-- 钩子实现
function plugin.on_request_end(payload)
-- payload 是解码后的 table,不是 JSON 字符串
log("info", string.format("%s via %s: %d prompt tokens",
payload.model, payload.source, payload.prompt_tokens or 0))
return nil -- 最后一个 stage 没有下游,return 无意义
end
-- 贡献界面
plugin.ui = {
page = {
page_id = "hello", -- kebab-case
title = "Hello",
icon = "👋",
order = 90, -- 侧栏排序
mount = [[
hello
]],
},
}
return plugin -- 必须返回一个 table
```
放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
---
## 2. 加载与生命周期
```
core.New
└─ lua.NewVM(adapter_dir).Start() 适配器状态
└─ lua.NewPlugins(vm, plugin_dir)
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
└─ LoadDir() 按文件名字典序逐个加载
```
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
的文件说了算,删除或编辑内置插件都是真实生效的操作。
### 2.1 热更新
| 方式 | 效果 |
|---|---|
| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
| 改文件后 `POST` 同名 | 同上 |
改文件但**不** POST,需要重启才生效。
---
## 3. 流水线 stage
一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
被忽略,所以插件不会因为网关将来新增 stage 而报错。
```
客户端请求
│
┌─────────▼──────────┐
│ request_start │ 已解析、已鉴权,尚未选源
│ · type │ "chat" | "stream" | "image"
│ · model │ 客户端请求的原始 model("AUTO" 也在这里)
│ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
│ · source │ 空(还没选源)
│ · stream │
│ · messages_count │
│ · tools_count │
│ · ts │ unix 秒
└─────────┬──────────┘
│ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
┌─────────▼──────────┐
│ routed │ 已选定 (source, model),尚未发往上游
│ · source / model │ 实际选中的
│ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
│ · stream / key │
└─────────┬──────────┘
│ (HTTP 往返 / SSE 流)
┌─────────▼──────────┐
│ request_end │ 每个请求恰好一次,成功失败都触发
│ · ok / status │
│ · latency_ms │
│ · first_byte_ms │ 流式的首字节时间
│ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
│ · completion_tokens
│ · cache_hit_tokens / cache_miss_tokens
│ · image_count │ 生图数量(图片不计 token)
│ · error │ 失败原因,成功时为 ""
│ · time │ unix **毫秒**
└─────────┬──────────┘
│
写审计 + 聚合统计
```
### 3.1 触发点在哪
| stage | 代码位置 | 说明 |
|---|---|---|
| `request_start` | `gateway/chat.go` `handleChat` | 每个 chat 请求一次 |
| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
反模式。
### 3.2 钩子的返回值
- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
---
## 4. 界面扩展
### 4.1 整页
```lua
plugin.ui = {
page = {
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
title = "Billing", -- 必填,侧栏文字
icon = "💰", -- 可选
order = 40, -- 侧栏排序,默认 100
mount = [[...HTML...]],
},
}
```
### 4.2 往现有页面追加元素
```lua
plugin.ui = {
elements = {
{
target = "status", -- status | chat | keys | sort | sources | adapters
anchor = "top", -- "top" | "bottom" | "before:" | "after:"
order = 5,
mount = [[...HTML...]],
},
},
}
```
### 4.3 `mount` 里可以带 `