按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
25 KiB
插件系统(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 目录里。
插件能做两件事:
- 挂钩子:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息, 并可以把结果累加进自己的状态。
- 贡献界面:在启动时返回 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 / 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/llmsproxy/plugins"
}
hook_errors 是排错入口:一个坏掉的插件表现为"功能缺失"而不是报错,
这个计数让它可见。
5.4 定时任务(PLUGIN.schedule)—— 浏览器关着也会跑
要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个 调度表:
plugin.schedule = {
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
{ fn = "daily_checkin", 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 在运行时安装/更新适配器(token 轮换)
插件可以在运行时安装、改写自己的适配器 —— 这是"带自动轮换的认证"的实现
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
的池)。可直接在插件 .lua 里调用:
-- 每小时的定时任务:轮换 token 并重写适配器
function plugin.refresh_token()
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
local name, err = __llmsproxy_adapters.install(
"myauth",
'return { name = "myauth", endpoint = "/chat/completions",' ..
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
)
if err then log("error", err) end
end
| 调用 | 参数 | 返回 |
|---|---|---|
__llmsproxy_adapters.install(name, source) |
适配器名 + Lua 源码 | (生效名, nil) 或 (nil, err) |
__llmsproxy_adapters.update(name, source) |
同上(只限自己装的) | (true, nil) 或 (nil, err) |
__llmsproxy_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,不要占用 - 插件间状态完全隔离,一个插件改不了另一个的全局
7. 内置计费插件
plugins/billing.lua,默认随首启 seed 进来,开箱可用。
7.1 计费维度
| 维度 | 用途 | 优先级 |
|---|---|---|
sources |
源的固定价(如按请求计费的转发服务) | 中 |
models |
模型的 per-token 价 | 高 |
keys |
单个网关 key 的覆盖价 | 最高 |
token 价优先级:keys > models > default。
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 就去补价目。
注意"未定价"不等于"免费":这些请求的 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 llmsproxy -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。