Files
ModelRouter/docs/plugins.md
JianFeeeee 42764bc99e feat(plugin): AUTO 调度轨迹可见(chain_step stage)
被问"还有 auto 调度相关 stage 呢?"问出来的真实缺口。

## 问题
chainDrive 只返回 (resp, src, model, err),调用方只知道**最终哪个槽位赢了**。
遍历过程中算出来又丢掉的东西——哪些档被跳过、为什么跳过、哪些槽位硬失败、
哪档全忙——一律不可见。ChainErr 里其实有这些,但**只在全部失败时**才填,
而它是 error 返回值不是记录。于是:

    "tier 1 冷却所以降级到 tier 3"  ==  "tier 1 正常接单"

对插件而言 tier 只是个常量 -2("resolved by the chain"),信息量为零。而这
恰恰是优先级链存在的全部理由,也是"我那个贵模型为什么没被用"的答案。

## 做法(scheduler 侧零新依赖)
新增 TraceEvent / TraceSink,chainDrive 多一个可选 sink 参数:

  - TraceEvent 是本包的普通 struct,sink 是 func 参数 ⇒ **不新增 import**,
    scheduler 仍然可独立测试
  - sink 为 nil 时每次 emit 只多一次 nil 判断;没有插件的网关在 AUTO 热路径上
    零开销(gateway 的 chainTraceSink 直接返回 nil)
  - 事件是纯观测:scheduler 不基于它做任何分支,gateway 也不把它喂回路由/
    冷却/配额

四种 kind:tier_skip / slot_fail / tier_busy / selected,selected 每次成功
遍历恰好一次且是最后一步。顺序保证所有 step 在 routed 之前。

## 暴露给插件
新增 chain_step stage(逐个步骤),并在 request_end 载荷里加三个便于做报表的
字段:chain_walk(上限 12 步,防审计记录膨胀)、degraded、tier_served。

## ★ 计费口径(我按推荐的做,已写进文档,需要你确认)
**按实际服务的模型计费**:降级到 tier 3 仍按 tier 3 的价算,轨迹只作观测。
理由与 §7.5 的边界一致——插件只报表不执法,两套口径混在一起会引出"降级该不该
多收钱"这种无法从代码判断的争议。若要改成"按本该用的档计价",需要在 models
价目里允许按 tier 定价,这我没做,因为那是个产品决策。

## 计费插件同步消费
by_tier_served / skip_reasons / degraded_reqs 三个新维度。skip_reasons 的等待
时长做了归一(`no free slot within <wait>`),否则 busy-wait 文案一变就多一行。
降级次数在 request_end 里计而不是在 chain_step 里计:一次降级的请求要走多步,
按步计会重复计数。

## 判据(346 个测试全绿,新增 15 个)
  scheduler  6 个:正常路径只发一个 selected / 跳档+降级可见 / 硬失败与跳档
                严格区分(不可混为一谈,否则抖动上游看起来像空闲上游)/
                nil sink 安全 / 全失败时轨迹与 ChainErr 并存且不互相破坏 /
                空链不发事件
  gateway    1 个端到端:tier 1 全 500 → 插件收到 slot_fail(tier 1) +
                selected(tier 2),request_end 的 tier_served=2 且 degraded=true
  lua        2 个:降级计数与按实际模型计价 / 跳过原因归一聚合
  lua        1 个:chain_step 是真 stage 且顺序正确

3 个变异都红:去掉 slot_fail(3 个判据红)/ 去掉 tier_skip(1 个)/
去掉 degraded 字段(1 个)。
2026-10-02 01:03:39 +08:00

453 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 插件系统(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 = [[<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` / `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:<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` 的响应
```json
{
"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 数,全程不做单位换算。
```json
{
"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 } }
}
}
```
配置价目:
```bash
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}}}}'
```
读回账目:
```bash
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`。
另有两个**降级观测**维度(来自 `chain_step`):
| 字段 | 含义 |
|---|---|
| `degraded_reqs` | 发生过降级的请求数 |
| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) |
| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within <wait>`) |
这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康
网关看起来一模一样——**除非**单独统计降级次数。
### 7.4 计费策略:失败的请求怎么算
**保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗
了;但那个从未真正发生的固定费不该收。
这是本插件里**最可争议的一条**。想改成"失败也收固定费":
```json
{ "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](lua-adapters.md)。