Files
ModelRouter/docs/plugins.md
JianFeeeee 8f2c5572e7 chore!: 移除厂商私有协议适配器与渠道业务插件(合规清理)
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类
能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id,
属于绕过客户端校验获取额度,风险高于普通协议适配。

移除(93 个文件):
- 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode
- 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针)
- 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档

同时修掉的既有问题:
- **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处)
  随 channels.lua 一起消失。
- `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提
  把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约
  冲突。现在部署脚本只同步适配器与二进制/配置。
- 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已
  移除,该测试此前已失效)。

保留(公开协议):openai / anthropic / gemini / deepseek / github / groq /
mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。

验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、
gateway 122.8s、e2e 9.0s 全绿。
2026-10-27 09:25:58 +08:00

32 KiB
Raw Permalink 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——可以是一整个 新页面,也可以是往现有页面里追加一个组件。

两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。


0. 插件 SDK:唯一的公开接口

插件与网关之间只有一个公开接口表 modelrouter。它不是 Go 包的导出符号, 而是内核在启动插件 VM 时注入的 Lua 表;插件通过它调用内核能力,永远不需要 (也不应该)依赖任何 __llmsproxy_* 全局、Go 结构体或内部函数名。

modelrouter.sdk_version   -- 字符串,契约版本;SDK 形状变化才递增
modelrouter.http(spec)    -- 受出站白名单约束的 HTTP 请求
modelrouter.secrets       -- 本插件专用的凭据保险库(set/get/delete/keys)
modelrouter.kernel        -- 内核动作面(actions/call,受运维授权约束)
modelrouter.adapters      -- 安装/更新/删除本插件自己的适配器
modelrouter.plugin_dir    -- 本插件所在目录(内核给出的 ground truth)

规则:

  • 只依赖上表。内核重命名内部全局、改实现或加守卫时,插件不受影响; 这正是把六个散落的全局收进一张表的原因。
  • 能力可能缺失。未授予的能力(例如出站 HTTP 全禁)在表里是 nil 而不是 "存在但一调用就报错",插件应先判断再使用。
  • sdk_version 用来做兼容分支。增量字段不递增版本;字段删除或调用约定 变化才递增。
  • 历史别名:llmsproxy(旧产品名)与 __llmsproxy_{http,secrets,kernel, adapters,plugin_dir} 仍然指向同一批对象,只为已安装的插件继续可用而保留, 已废弃;新插件使用 modelrouter.*。
  • 环境变量:插件目录优先读 MODELROUTER_PLUGIN_DIR,回退旧名 MODELROUTER_PLUGIN_DIR。

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)
`plugin_dir` 中的 Lua 文件由网关逐个加载;安装包不携带业务插件,也不会自动创建或写入插件文件。首次启用时,先把需要的 `.lua` 插件放入该目录,再启动网关。

加载失败不影响网关启动。 一个语法错误的插件会被记录在 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 的多个插件并行执行,但返回值按插件加载顺序合并,所以结果是 确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段: 每个钩子拿到的是同一份 payload 快照。

这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被 实际依赖(随核心发布的 billing 在每个 stage 都 return nil,注释里写着 "nobody downstream would read a return value"),但这是一处契约变化:如果你的 插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信 (例如各自写 plugin.state,由 /api/plugins/<name>/state 读取)。

单个插件时不启 goroutine,直接调用。

前三个 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/modelrouter/plugins"
}

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

5.4 定时任务(PLUGIN.schedule)—— 浏览器关着也会跑

要求插件做“随时间发生”的事(定时刷新、token 轮换、指标采集)时,声明一个 调度表:

plugin.schedule = {
  { fn = "refresh_token", every = 3600 },                 -- 每小时(秒)
  { fn = "daily_refresh", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
  { fn = "poll_quota",    every = 300, jitter = 30 },     -- 首次触发加 0-30s 抖动
}
function plugin.refresh_token()
  -- 注意:直接写 plugin.state 就是对的 ——
  -- 任务和钩子跑在同一个 VM 上
  plugin.state.token = fetch_new_token()
end
字段 含义
fn 要调用的插件函数名(必须存在)
every 间隔秒数。与 at 二选一
at 每天本地时间 "HH:MM";设了它就忽略 every
jitter 首次触发额外随机延迟秒数(避免多实例整点齐射)
timeout 解析保留但目前不生效(见下)

要点:

  • 内核 ticker 驱动,不是前端轮询:没有浏览器、没人登录也会执行。
  • 任务跑在插件自己的 VM 上,与请求钩子共用 plugin.state —— 任务写的 token 对下一个请求立即可见,也会经同一套机制持久化到 .<name>.state.json。
  • 任务函数出错只记 hook_errors,不影响钩子与转发。
  • 没有 schedule 的插件不获得任何额外 VM/goroutine(零开销)。
  • 超时不能中断一个运行中的 Lua 调用(本 LuaJIT 构建的硬限制)。任务超过 10 分钟不返回,该插件被停用(disarmed):不再调度,钩子快速失败并 记 hook_errors。这是为了不让一个卡死的任务把请求永久堵在它那把锁后面。 修好插件后重新加载(重传/重启)即可恢复。

同一条 6.1 的约束:任务占着插件锁,所以任务里不要写死循环。

5.5 自定义对外挂载点

插件的 HTTP 端点默认挂在 /api/plugins/http/... 下。同一个声明同时可在两个 公开命名空间下访问,只要把 path 的第一段起成插件名:

plugin.http = {
  { path = "myplugin/callback", method = "POST", handler = "on_callback" },
}

该端点因此可在三个地方访问:

路径 用途
/api/plugins/http/myplugin/callback 默认前缀(带登录态的管理 UI)
/plugins/myplugin/callback 公开(浏览器路径)
/v1/plugins/myplugin/callback 公开(API 路径)

可占用的命名空间只有后两个:

前缀 例子
/plugins/ /plugins/myplugin/callback
/v1/plugins/ /v1/plugins/myplugin/callback

限制(都是为了不把内核路由变成可被插件改写的):

  • 不能影子内核路由:与 /v1/chat/completions、/v1/messages、/api/... 等重叠的挂载在加载时就被拒绝,错误信息点名冲突对象。
  • 只能是上面两个命名空间,不接受任意前缀(否则可以拦截推理请求)。
  • 鉴权沿用端点的 roles / keys / public 语义(见 §5.1); /v1/plugins/... 属于 API 路径,未认证返回 401;/plugins/... 为浏览器 路径,未认证会跳登录页。
  • 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。

5.6 前缀代理:把插件自起的服务挂到网关同端口

前节的自定义挂载点只能回答由插件计算出来的内容({status, headers, body/json})。 若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要 字节级转发,包括 WebSocket 升级 —— 前者做不到。

为此声明 prefix = true,handler 每次请求返回一个回环目标:

plugin.http = {
  { path = "myplugin/proxy/:session", method = "GET", prefix = true,
    roles = { "admin" }, handler = "proxy" },
}

function plugin.proxy(req)
  -- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
  if not sessionAlive(req.params.session) then
    return { status = 404, json = { error = "no such session" } }
  end
  return { proxy = "http://127.0.0.1:60100" }
end

规则:

  • 只能返回 { proxy = <目标> },没有 status / body。写了 status 会被忽略。
  • 目标必须是 带显式端口的字面回环 IP(http://127.0.0.1:60100)。 拒绝 https://、localhost 这类主机名、无端口、带 userinfo/path/query 的地址。 理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
  • 路由匹配同前缀下的所有子路径,:param 仍照常捕获,req.path 是完整 请求路径,子路径由插件自己从 req.path 里切。
  • 鉴权先于代理:roles / keys / public 语义与普通端点一致。
  • 不转发网关凭据:Authorization、Cookie、api_key 都不会传给后端服务。
  • 拒绝跨站:Origin 与网关自身 host 不一致返回 403。WebSocket 由浏览器 发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
  • 撤销点唯一:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。

客户端拿到的 URL 是相对网关口的绝对路径(如 /api/plugins/http/myplugin/proxy/<id>/vnc.html),因此远程访问、HTTPS 入口、 Cookie 鉴权都自然生效,无需暴露额外端口。

5.7 在运行时安装/更新适配器(token 轮换)

插件可以在运行时安装、改写自己的适配器 —— 这是"带自动轮换的认证"的实现 方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器 的池)。可直接在插件 .lua 里调用:

-- 每小时的定时任务:轮换 token 并重写适配器
function plugin.refresh_token()
  local tok = fetch_new_token()          -- 插件自己的取 token 逻辑
  local name, err = modelrouter.adapters.install(
    "myauth",
    'return { name = "myauth", endpoint = "/chat/completions",' ..
    ' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
  )
  if err then log("error", err) end
end
调用 参数 返回
modelrouter.adapters.install(name, source) 适配器名 + Lua 源码 (生效名, nil) 或 (nil, err)
modelrouter.adapters.update(name, source) 同上(只限自己装的) (true, nil) 或 (nil, err)
modelrouter.adapters.remove(name) 适配器名(只限自己装的) (true, nil) 或 (nil, err)

出错时不抛异常,而是返回 (nil, err)——与钩子/HTTP 处理器同一套约定, 一个手滑的插件错误不会带走网关。

安全规则(由 Go 侧强制,不是约定):

  • 插件只能改自己装的适配器(归属表在 Go 侧记录,调用身份是插件启动时的 固定名字,无法冒充)。
  • 插件不能影子内置适配器(否则等于篡改发行物);运维仍可自行覆盖。
  • 名字经过路径安全校验(无穿越/绝对路径/空白)。

这条能力配合 §5.4 的定时任务,就是完整的自动轮换:没有浏览器、没有人 操作时,插件按时间自己把 token 换掉。


6. 运行时约束(重要)

6.1 一个插件 = 一个 Lua 状态(也就是一个 VM)

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

因此插件持有唯一一个状态,所有入口(Fire / State / SetState / 定时任务) 都用这同一把互斥锁串行化。

注意: 这条约束对定时任务尤其关键:任务和钩子必须是同一个 VM,否则任务写的 plugin.state 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了, 对实际流量完全无效。

代价:一个卡住的钩子会卡住该插件自己的钩子(跨插件仍然并行,每插件各自 一把锁、各自一个 VM)。所以——

钩子与定时任务都必须短、同步、不阻塞。 不要在钩子里做 HTTP 请求、睡眠 或重计算;长耗时的周期工作应放进 PLUGIN.schedule(见 §5.4),它在自己的 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。

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 的内部位置,不属于 SDK,不要占用
  • 插件间状态完全隔离,一个插件改不了另一个的全局

7. Billing 插件示例

internal/lua/plugins/billing.lua 是仓库中的可选插件示例,不随核心安装包分发,也不会在启动时自动安装。需要使用计费功能时,应将该脚本单独复制到配置的 plugin_dir 后再重启网关。

7.0 分层:billing 完全在插件里,内核没有任何 billing 代码

billing 是插件自己的事:规则文件、编译、计价、账目、规则编辑页都由 plugins/billing.lua 负责。内核里没有 billing 的配置结构、端点、编译或迁移逻辑 —— 它只提供所有插件通用的能力(加载 .lua、插件状态持久化、插件目录、 plugin.http 路由、插件可用的 io/os)。

项 位置
规则文件 <plugin_dir>/billing.json(插件自己读写;内核不知道这个文件)
计费账目 插件状态文件 <plugin_dir>/.billing.state.json(内核的通用状态持久化)
规则编辑 API /api/plugins/http/billing/rules(GET/PUT/POST/DELETE,插件路由)
编译/计价 插件内 compileRules() / priceFor() / costFor()

规则以源名(source)为键,而不是 URL:URL→源名的映射属于内核配置,插件 不需要为了它和内核对接;编辑页在浏览器里,直接取 /api/sources 给运营选源名。 url: "*" 只表示"全局模型价目表"(模型维度本来就不分源)。

历史:早期 billing: 写在 config.yaml、编译在 Go 的 internal/billing 里。 GitCode issue #1 的根因正是"Go 与 Lua 各写一半覆盖语义",组合起来把模型价 静默清零。现在内核侧 billing 代码已全部删除,规则文件也由插件自持。

7.1 计费维度### 7.1 计费维度

维度 用途 token 价覆盖顺序
default 兜底价(通常全 0) 最低
models 模型的 per-token 价 中
sources 源级价:固定价,或覆盖该源全部模型的 token 价 高
keys 单个网关 key 的覆盖价 最高

token 价优先级:keys > sources > models > default。 (实现顺序就是"后写覆盖前写":default → models → sources → keys; 所以源级 token 价会覆盖模型价。这是有意的:运营要能给某个源整体改价。)

⚠️ 正因为 sources 会覆盖 models:mode: token 的规则不产生任何源条目。 规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2 的收口语义。只有 free / subscription("边际成本为零")会写有意为之的 {prompt:0, completion:0} 零价条目。

规则写法:

{"active":"current","profiles":[{"id":"current","default":"unpriced","rules":[
  {"source":"localzen","mode":"free"},
  {"source":"codearts","mode":"token",
   "models":{"glm-5.3":{"prompt":"0.15","completion":"0.60"}}},
  {"url":"*","mode":"token","models":{"some-model":{"prompt":"1.0","completion":"2.0"}}}
]}]}

per_request 固定价是叠加的(不覆盖),所以一个生图模型可以既算 token 又收固定费。 cache_discount 见 §7.7。

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。

另有两个降级观测维度(来自 chain_step):

字段 含义
degraded_reqs 发生过降级的请求数
by_tier_served 各档实际接单数({"1": 812, "2": 37})
skip_reasons 跳过原因计数,等待时长已归一(no free slot within <wait>)

这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康 网关看起来一模一样——除非单独统计降级次数。

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

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

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

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

7.5 注意: 计费插件只报表,不执法

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

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

7.6 未定价流量(重要)

任何维度都没配价的请求,成本记 0。 这是最危险的失败模式:账单照样能加总, 只是悄悄少报,而且没有任何报错。

所以插件单独统计它们:

字段 含义
unpriced_reqs 没有任何价目覆盖的请求数
unpriced_models 按模型点名({"MYSTERY-MODEL": 12})——直接告诉你价目表缺了哪一行

仪表盘上有 "Unpriced" 卡片。这个数应该是 0;不是 0 就去补价目。

profile 级兜底:profiles[].default 可设为 free 或 unpriced(默认)。 它是插件运行时的策略(编译期写入 prices.default_mode):

default 没有单独规则的源 unpriced_reqs
unpriced(省略时) 零价,计入未定价债务 +1
free 零价,视为刻意免费 不增加

显式 mode: unpriced 的源即使在 default: free 下也仍然计入("只统计不收费" 的声明不能被兜底策略吞掉)。

注意"未定价"不等于"免费":这些请求的 requests / token 数照常计入 total 与各维度,只有金额是 0。

7.7 提示缓存计价

缓存命中的 prompt token 不按全价算。 绝大多数 provider 对缓存读给很深的折扣 (常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。

fresh  = prompt_tokens - cache_hit_tokens   → 全价
cached = cache_hit_tokens                   → 全价 × cache_discount

cache_discount 默认 0.1(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个 量级。它是每个 provider 的事实、不是自然常数,所以可以按条目覆盖:

"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } }

设成 1 恢复成旧的"prompt 一律全价"行为,设成 0 表示该 provider 不打折。

优先级与 token 价一致(keys > models > default)。

这一条改过行为。 修复前缓存命中按全价算:100 万 prompt token 里 90 万是 缓存命中,会算出 10 USD 而不是 ~1.9——高估约 10 倍,而且恰好发生在缓存 最有价值的高频流量上。

7.8 数据从哪来

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 modelrouter -f | grep -i plugin

9. 与适配器的区别

适配器 插件
位置 adapter_dir plugin_dir
作用 上游协议转换 请求流水线 + 界面 + 周期任务
钩子 transform_request / transform_response / transform_stream_chunk / build_headers / transform_error / serialize_* request_start / routed / request_end
周期任务 无(无状态请求级) PLUGIN.schedule(内核 ticker 驱动)
对外端点 不开放(见 §5.5) /api/plugins/http/... + 自定义挂载
状态 每个源独立,多 worker 单 VM、单状态,见 §6.1
出错后果 该 (源,模型) 进入冷却退避 仅该功能缺失;卡死则停用该插件
鉴权 需要 gateway_keys 之外的独立凭据 网关 key
必需性 核心 可选,缺了网关照常跑

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