# 插件系统(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` 里可以带 `