# 插件系统(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` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** | | `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 | | `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 | | `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 | ### 3.1b 为什么单独有 `chain_step` `routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以 "tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**—— 而这恰恰是优先级链存在的全部理由。 `chain_step` 补上这条信息,四种 `kind`: | kind | 含义 | 何时产生 | |---|---|---| | `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 | | `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 | | `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 | | `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 | 顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。 所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。 ### 3.1c `request_end` 里的轨迹摘要 除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段: | 字段 | 含义 | |---|---| | `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) | | `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** | | `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` | > **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算, > `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。 > 理由见 §7.5。 `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` 里可以带 `