Files
ModelRouter/docs/plugins.md
JianFeeeee a51a6811a6 feat(plugin): Lua 插件机制 + 计费插件 + 插件文档
插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时
贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。

## 流水线 stage(三个)
  request_start  已解析鉴权、未选源
  routed         已选定 (source, model)、未发往上游
  request_end    每请求恰好一次,带最终计量
request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的
唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。

## 计费插件(plugins/billing.lua,默认 seed,开箱可用)
源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request
固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token,
即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。
失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。
界面 = 一个独立页 + 状态页顶部一块总开销 tile。

## 一个明确的设计边界
计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额
权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的
更糟。

## ★ 中途改掉的一个根本设计错误
最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无
状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成
几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求
按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞,
卡住的钩子会卡住所有插件的钩子。
这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。

另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为
prices/state 分离——prices 是配置、state 是历史,改价不动账。

## 撞到的三个 Lua 绑定的坑(都写进注释)
  - SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil
  - GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了
  - Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个;
    传索引会调到参数上("attempt to call a table value")
另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。

## 错误隔离
钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错
会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors
让"坏掉的插件"可见而不是静默消失。

## 界面注入
GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。
WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 <script> 让它执行
(innerHTML/template 插入的 script 不会执行,这正是要的效果——避免脚本跑在
自己 DOM 之前)。注入失败不影响仪表盘。
browser 侧 pluginAPI 暴露 fetchState / postState / onTabShown。

## 文档
docs/plugins.md —— 快速上手、加载与热更新、三个 stage 的完整字段表、界面扩展、
状态与 HTTP API、运行时约束(单状态/异常隔离/内置函数)、计费插件的定价与
计费策略、排错表、与适配器的对比表。

## 判据(328 个测试全绿,插件相关 33 个)
  - 计费断言的是**具体金额**(0.00625 / 0.0402 / 0.0075…),不是"能加载"
  - 4 个变异都红:钩子异常不隔离 / prices 清空累计 / 忽略 key 优先级 /
    毫秒时间戳不换算
  - UI 侧 6 个判据把注入顺序、script 执行时机、pluginAPI 名称、tab 路由、
    anchor 四种形式、失败非致命全钉住
  - 鉴权:state 读任意角色、写仅 admin
2026-10-02 00:37:29 +08:00

14 KiB
Raw Blame History

插件系统(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. 快速上手

一个最小的插件:

-- 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 = [[<div id="hello">hello</div>]],
  },
}

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 整页

plugin.ui = {
  page = {
    page_id = "billing",   -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
    title = "Billing",     -- 必填,侧栏文字
    icon = "💰",           -- 可选
    order = 40,            -- 侧栏排序,默认 100
    mount = [[...HTML...]],
  },
}

4.2 往现有页面追加元素

plugin.ui = {
  elements = {
    {
      target = "status",   -- status | chat | keys | sort | sources | adapters
      anchor = "top",      -- "top" | "bottom" | "before:<sel>" | "after:<sel>"
      order = 5,
      mount = [[...HTML...]],
    },
  },
}

4.3 mount 里可以带 <script> 和 <style>

内核的注入顺序是:先插 HTML,再执行 <script>,所以脚本里访问 document.getElementById 一定能拿到已渲染的节点。

4.4 插件可用的浏览器端 API

API 作用
window.pluginAPI.fetchState(name) 等价于 GET /api/plugins/<name>/state
window.pluginAPI.onTabShown(fn) 注册"页面切到可见时"的回调(轮询类组件用)
window.pluginAPI.postState(name, obj) PUT 自己的 state(写操作,需 admin)

4.5 一个完整例子

见 internal/lua/plugins/billing.lua——它同时用了两种 UI 形式、完整的价格配置、 以及全部三个 stage。


5. 插件状态与 HTTP API

5.1 端点

GET    /api/plugins                  已加载插件清单 + 钩子 + UI + 错误
POST   /api/plugins                  上传/替换(admin){name, code}
DELETE /api/plugins/{name}           删除(admin)
GET    /api/plugins/{name}/state     读取插件自己发布的状态
PUT    /api/plugins/{name}/state     替换状态(admin)

GET .../state 对任意角色开放:它是报表数据(开销、计数),用户自己的 计费组件要能渲染。而 PUT 需要 admin。

5.2 约定:prices 与 state 分离

PUT .../state 的载荷里如果有 prices 键,它会被写进插件的 plugin.prices 字段并从 state 里剔除。

为什么:state 是累计量(历史),prices 是配置。一次改价如果整体替换 state,累计量就没了——账目会在改价那一刻清零。分离之后:

  • 只带 prices → 改配置,不动 state(累计量保留)
  • 带其它键 → 替换 state(这是显式的重置)

插件可以不遵守这个约定,把整个载荷放进 state 自行处理。约定只为内置的 计费插件存在。

5.3 GET /api/plugins 的响应

{
  "plugins": [{
    "name": "billing", "version": "1.0.0",
    "description": "...", "author": "...",
    "hooks": ["request_end"],
    "ui": { "page": "billing", "elements": 1 },
    "loaded": true
  }],
  "hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
  "plugin_dir": "/etc/llmsproxy/plugins"
}

hook_errors 是排错入口:一个坏掉的插件表现为"功能缺失"而不是报错, 这个计数让它可见。


6. 运行时约束(重要)

6.1 一个插件 = 一个 Lua 状态

适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。插件不行: 钩子通常往 plugin.state 里累加,多个状态就意味着总额被劈成几份,而写价格只 写进其中一个状态,钩子恰好跑到另一个时所有请求按 0 计费。

因此插件持有唯一一个状态,所有入口(Fire / State / SetState)用互斥锁 串行化。代价:一个卡住的钩子会卡住所有插件的钩子。所以——

钩子必须短、同步、不阻塞。 不要在里面做 HTTP 请求、睡眠或重计算。

6.2 异常被隔离

钩子里 error() 不会影响转发:异常被捕获、记进 hook_errors、跳到下一个插件。 适配器变换出错会让源进入冷却,但插件出错不产生任何惩罚——插件是可选功能。

6.3 内置可用函数

与适配器相同:json.encode / json.decode、log(level, msg)、 hmac_sha256_hex / sha256_hex / base64_encode / tohex。

⚠️ os.date 在极简运行时里可能不可用(计费插件因此有降级路径)。

6.4 命名

  • 全局 __llmsproxy_plugin 存放插件返回的 table,不要占用
  • 插件间状态完全隔离,一个插件改不了另一个的全局

7. 内置计费插件

plugins/billing.lua,默认随首启 seed 进来,开箱可用。

7.1 计费维度

维度 用途 优先级
sources 源的固定价(如按请求计费的转发服务) 中
models 模型的 per-token 价 高
keys 单个网关 key 的覆盖价 最高

token 价优先级:keys > models > default。 per_request 固定价是叠加的(不覆盖),所以一个生图模型可以既算 token 又收固定费。

7.2 价格单位

USD / 单个 token。这是各家 provider 的公布口径,所以典型数值长这样: 1.25e-6。插件内部乘以 token 数,全程不做单位换算。

{
  "prices": {
    "currency": "USD",
    "default":  { "prompt": 0, "completion": 0, "per_request": 0 },
    "sources":  { "trae": { "per_request": 0.01 } },
    "models":   {
      "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5 },
      "kolors":  { "per_request": 0.04 }
    },
    "keys":     { "***a1b2c3": { "prompt": 1.1e-6, "completion": 9e-6 } }
  }
}

配置价目:

curl -X PUT http://127.0.0.1:8080/api/plugins/billing/state \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"prices":{"models":{"gpt-5.4":{"prompt":1.25e-6,"completion":1e-5}}}}'

读回账目:

curl -H "Authorization: Bearer $ADMIN_KEY" \
  http://127.0.0.1:8080/api/plugins/billing/state

7.3 累计维度

total / by_source / by_model / by_key / by_day(YYYY-MM-DD UTC), 每项含 cost、requests、prompt_tokens、completion_tokens、failures。

7.4 计费策略:失败的请求怎么算

保留 token 费用,丢弃固定费用。 理由:上游在生成后才 500,token 确实被消耗 了;但那个从未真正发生的固定费不该收。

这是本插件里最可争议的一条。想改成"失败也收固定费":

{ "prices": { ... }, "count_failures": true }

7.5 ⚠️ 计费插件只报表,不执法

网关自己的配额会计(internal/gateway/stats.go,入口处强制)才是限额权威。 本插件不参与任何路由或配额决策。

理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是观察, 配额是控制,二者分开。

7.6 数据从哪来

request_end 的 prompt_tokens / completion_tokens 优先取上游真实的 usage;上游没报时网关用字节估算(len/3+1)。流式请求在流结束后用上游真实 数字覆盖估算值。所以计费数字的精度取决于上游是否报 usage。


8. 排错

现象 查什么
插件没出现在 /api/plugins 目录对不对(响应里的 plugin_dir);文件是不是 .lua
loaded: false 且有 error 语法错误或没 return table,错误信息在 error 字段
功能"没反应"但无报错 查 hook_errors——坏钩子只记录不抛出
钩子没被调用 该 stage 确实触发了吗(routed 只在成功选源后触发,调度全失败时不触发)
界面空白 内核的 GET /api/ui-inject 里有没有你的 page_id;脚本有没有报错(浏览器 console)
pluginAPI 未定义 脚本在注入前执行了;确认用的是 mount 而不是别的方式插入

调试钩子

插件的 log(level, msg) 输出到网关的日志:

journalctl -u llmsproxy -f | grep -i plugin

9. 与适配器的区别

适配器 插件
位置 adapter_dir plugin_dir
作用 上游协议转换 请求流水线 + 界面
钩子 transform_request / transform_response / transform_stream_chunk / build_headers / transform_error request_start / routed / request_end
状态 每个源独立,多 worker 单状态,见 §6.1
出错后果 该 (源,模型) 进入冷却退避 仅该功能缺失
鉴权 需要 gateway_keys 之外的独立凭据 网关 key
必需性 核心 可选,缺了网关照常跑

完整适配器协议见 lua-adapters.md。