mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 15:07:51 +00:00
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
This commit is contained in:
@ -4815,8 +4815,150 @@
|
||||
if (tab === "keys") return renderKeys();
|
||||
if (tab === "sort") return renderSort();
|
||||
if (tab === "sources") return renderSources();
|
||||
return renderAdapters();
|
||||
if (tab === "adapters") return renderAdapters();
|
||||
// A page contributed by a plugin has no renderer here: its <script>
|
||||
// already ran at injection time and owns its own DOM. We only fire the
|
||||
// "shown" callbacks so it can refresh when the user lands on it.
|
||||
if (PLUGIN_PAGES.has(tab)) return notifyPluginTab(tab);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- plugin injection -------------------------------------------
|
||||
// Pages and elements contributed by Lua plugins (see docs/plugins.md).
|
||||
//
|
||||
// The server merges every plugin's extension into one payload at
|
||||
// GET /api/ui-inject, because the sidebar needs all of them before it can
|
||||
// be built. Injection happens once at boot, BEFORE the first goTab, so a
|
||||
// plugin page is a real tab rather than a special case in the router.
|
||||
const PLUGIN_PAGES = new Set();
|
||||
const PLUGIN_TAB_CBS = {};
|
||||
const PLUGIN_ELEMENTS = [];
|
||||
|
||||
// pluginAPI is the small surface a plugin's script may rely on. Kept
|
||||
// deliberately tiny: plugins are untrusted, and every convenience here is
|
||||
// one more thing to keep working across kernel changes.
|
||||
window.pluginAPI = {
|
||||
async fetchState(name) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) throw new Error("state " + r.status);
|
||||
return (await r.json()).state;
|
||||
},
|
||||
async postState(name, obj) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(obj),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
|
||||
return true;
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
},
|
||||
};
|
||||
|
||||
function notifyPluginTab(tab) {
|
||||
const fns = PLUGIN_TAB_CBS[tab] || PLUGIN_TAB_CBS.__last || [];
|
||||
fns.forEach((f) => {
|
||||
try {
|
||||
f();
|
||||
} catch (e) {
|
||||
console.warn("plugin tab callback failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// injectPluginUI adds the sidebar button + pane for a plugin page and
|
||||
// mounts plugin elements into existing panes.
|
||||
async function injectPluginUI() {
|
||||
let payload;
|
||||
try {
|
||||
const r = await fetch("/api/ui-inject", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
payload = await r.json();
|
||||
} catch (e) {
|
||||
return; // plugins are optional; the UI must work without them
|
||||
}
|
||||
const ui = (payload && payload.ui) || {};
|
||||
const main = $("#main");
|
||||
const nav = $("#sb-nav");
|
||||
if (!main || !nav) return;
|
||||
|
||||
// --- page ---
|
||||
if (ui.page && ui.page.page_id && ui.page.mount) {
|
||||
const id = String(ui.page.page_id);
|
||||
if (!document.getElementById("tab-" + id)) {
|
||||
const pane = document.createElement("div");
|
||||
pane.id = "tab-" + id;
|
||||
pane.className = "tab-pane hidden";
|
||||
main.appendChild(pane);
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sb-i";
|
||||
btn.dataset.tab = id;
|
||||
btn.title = ui.page.title || id;
|
||||
btn.innerHTML =
|
||||
'<span style="font-size:18px;line-height:1">' +
|
||||
esc(ui.page.icon || "•") +
|
||||
"</span>";
|
||||
btn.onclick = () => goTab(id);
|
||||
nav.appendChild(btn);
|
||||
PLUGIN_PAGES.add(id);
|
||||
// The breadcrumb map is local to this file, so extend it here.
|
||||
if (typeof NAV_NAME === "object") NAV_NAME[id] = ui.page.title || id;
|
||||
}
|
||||
const pane = document.getElementById("tab-" + id);
|
||||
if (pane && !pane.dataset.pluginMounted) {
|
||||
pane.dataset.pluginMounted = "1";
|
||||
// Split the mount so <script>/<style> run only AFTER the markup is
|
||||
// in the document. Setting innerHTML with a <script> tag does not
|
||||
// execute it, which is exactly what we want to avoid the opposite
|
||||
// problem: running before its own DOM exists.
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = ui.page.mount;
|
||||
pane.appendChild(tpl.content);
|
||||
// Move each script into a fresh element so it executes.
|
||||
pane.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- elements into existing pages ---
|
||||
(ui.elements || []).forEach((el, i) => {
|
||||
const target = document.getElementById("tab-" + el.target);
|
||||
if (!target || !el.mount) return;
|
||||
PLUGIN_ELEMENTS.push(el);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "plugin-el";
|
||||
wrap.dataset.target = el.target;
|
||||
wrap.dataset.idx = String(i);
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = el.mount;
|
||||
wrap.appendChild(tpl.content);
|
||||
const anchor = String(el.anchor || "bottom");
|
||||
if (anchor === "top") target.prepend(wrap);
|
||||
else if (anchor.startsWith("before:") || anchor.startsWith("after:")) {
|
||||
const [kind, sel] = anchor.split(/:(.+)/);
|
||||
const ref = target.querySelector(sel);
|
||||
if (ref) ref.parentNode.insertBefore(wrap, kind === "before" ? ref : ref.nextSibling);
|
||||
else target.appendChild(wrap);
|
||||
} else target.appendChild(wrap);
|
||||
wrap.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api("/api/keys/me");
|
||||
@ -4836,7 +4978,15 @@
|
||||
window.addEventListener("pagehide", () => releaseRecords(false));
|
||||
window.addEventListener("beforeunload", () => releaseRecords(false));
|
||||
|
||||
refresh("status");
|
||||
// Plugin injection runs BEFORE the first render: a plugin page must exist
|
||||
// in #main and the sidebar before goTab runs, otherwise the sidebar shows
|
||||
// no entry and the pane is missing for a moment. Awaited (not fired and
|
||||
// forgotten) so a slow /api/ui-inject cannot race the first paint.
|
||||
injectPluginUI()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
refresh("status");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user