mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
插件 = 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
159 lines
6.5 KiB
Go
159 lines
6.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The plugin UI injection is JavaScript inside the embedded index.html, and it
|
|
// is the ONLY thing that turns a plugin's `ui` block into a visible page or
|
|
// element. These tests pin the wiring on the JS side; the server side (what the
|
|
// payload contains) is covered by TestUIInjectServesPluginUI and the lua
|
|
// package's TestBillingPluginDeclaresUI.
|
|
//
|
|
// What makes this worth pinning: a missing hook here fails SILENTLY. The page
|
|
// simply never appears, there is no error anywhere, and it looks like "the
|
|
// plugin didn't declare a page" rather than "the UI forgot to inject it".
|
|
|
|
// uiSource returns the embedded WebUI document.
|
|
func uiSourceX(t *testing.T) string {
|
|
t.Helper()
|
|
return uiSource(t)
|
|
}
|
|
|
|
// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to
|
|
// inject. Without this fetch the whole feature is inert.
|
|
func TestUIFetchesPluginInjection(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
if !strings.Contains(src, "/api/ui-inject") {
|
|
t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear")
|
|
}
|
|
}
|
|
|
|
// TestUIInjectsBeforeFirstRender: injection must be awaited before the first
|
|
// refresh, otherwise the sidebar is built without the plugin entry and the
|
|
// first paint races the fetch. This is an ordering contract, so it is asserted
|
|
// on the source order rather than trusted.
|
|
func TestUIInjectsBeforeFirstRender(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
iInject := strings.Index(src, "injectPluginUI()")
|
|
iRefresh := strings.LastIndex(src, `refresh("status")`)
|
|
if iInject < 0 {
|
|
t.Fatal("injectPluginUI() is never called")
|
|
}
|
|
if iRefresh < 0 {
|
|
t.Fatal("the boot sequence no longer calls refresh(\"status\")")
|
|
}
|
|
if iInject > iRefresh {
|
|
t.Error("injectPluginUI() is called after the first refresh; the sidebar " +
|
|
"and #main would be built before the plugin page exists")
|
|
}
|
|
// And it must be awaited, not fire-and-forget.
|
|
window := src[iInject:]
|
|
if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") {
|
|
t.Error("injectPluginUI() is not awaited before refresh; a slow response " +
|
|
"would race the first paint")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a
|
|
// <script> tag does NOT execute it; appending via a template neither does. The
|
|
// mount therefore has to be inserted first and its scripts re-created
|
|
// afterwards, or a plugin's script runs before its own DOM exists — which is
|
|
// exactly the "document.getElementById returns null" failure mode.
|
|
func TestUIPluginScriptsRunAfterMarkup(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// A <template> is used to parse the mount without executing scripts...
|
|
if !strings.Contains(src, "createElement(\"template\")") {
|
|
t.Error("the mount is not parsed via <template>; scripts could execute before their DOM")
|
|
}
|
|
// ...and scripts are then re-created as fresh elements so they DO run.
|
|
if !strings.Contains(src, "document.createElement(\"script\")") {
|
|
t.Error("plugin <script> blocks are never re-created, so they never execute")
|
|
}
|
|
if !strings.Contains(src, "replaceWith(s)") {
|
|
t.Error("the original inert <script> is not replaced by an executable one")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginAPISurface: the documented browser API must exist with the exact
|
|
// names docs/plugins.md promises, since plugin authors code against it.
|
|
func TestUIPluginAPISurface(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
for _, member := range []string{"fetchState", "postState", "onTabShown"} {
|
|
if !strings.Contains(src, member+":") && !strings.Contains(src, member+"(") {
|
|
t.Errorf("window.pluginAPI.%s is missing; docs/plugins.md documents it", member)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIPluginPageBecomesRealTab: a plugin page must get a pane in #main AND a
|
|
// sidebar button wired to goTab, otherwise the page is unreachable.
|
|
func TestUIPluginPageBecomesRealTab(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// pane in #main
|
|
if !strings.Contains(src, `pane.id = "tab-" + id`) {
|
|
t.Error("no pane is created for a plugin page")
|
|
}
|
|
if !strings.Contains(src, "main.appendChild(pane)") {
|
|
t.Error("the plugin pane is not appended to #main")
|
|
}
|
|
// sidebar button wired to the tab router
|
|
if !strings.Contains(src, "btn.dataset.tab = id") {
|
|
t.Error("the sidebar button is not given a data-tab, so goTab() will not route to it")
|
|
}
|
|
if !strings.Contains(src, "btn.onclick = () => goTab(id)") {
|
|
t.Error("the sidebar button is not wired to goTab()")
|
|
}
|
|
// and the router must know about it
|
|
if !strings.Contains(src, "PLUGIN_PAGES.has(tab)") {
|
|
t.Error("refresh() does not route plugin pages, so opening one renders nothing")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginElementsHonorAnchor: elements declare top / bottom / before:sel /
|
|
// after:sel. Silently ignoring the anchor would put a "top" tile at the bottom
|
|
// of the status page, which looks like a layout bug rather than a plugin bug.
|
|
func TestUIPluginElementsHonorAnchor(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
for _, anchor := range []string{`anchor === "top"`, `anchor.startsWith("before:")`, `"after:"`} {
|
|
if !strings.Contains(src, anchor) {
|
|
t.Errorf("the anchor form %s is not handled; elements would all land at the bottom", anchor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIPluginInjectionFailureIsNonFatal: plugins are optional, so a failed
|
|
// /api/ui-inject must still leave a working UI (the dashboard has to render).
|
|
// Two places have to cooperate: the function swallows the fetch error, and the
|
|
// caller catches anything that still escapes so refresh() always runs.
|
|
func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// inside the function: the fetch is wrapped in try/catch
|
|
fnStart := strings.Index(src, "async function injectPluginUI()")
|
|
if fnStart < 0 {
|
|
t.Fatal("injectPluginUI() is not defined")
|
|
}
|
|
fn := src[fnStart:]
|
|
if !strings.Contains(fn, "plugins are optional; the UI must work without them") {
|
|
t.Error("injectPluginUI does not guard its own fetch failure")
|
|
}
|
|
// at the call site: the rejection cannot escape before the first render
|
|
// LastIndex, not Index: the DEFINITION of injectPluginUI also matches, and
|
|
// the definition has no .catch on it.
|
|
iCall := strings.LastIndex(src, "injectPluginUI()")
|
|
if iCall < 0 {
|
|
t.Fatal("injectPluginUI() is never called")
|
|
}
|
|
// Bound the window at len(src): the call site sits near EOF and a fixed
|
|
// slice overruns it (a panic in a test is worse than a skipped assertion).
|
|
end := iCall + 220
|
|
if end > len(src) {
|
|
end = len(src)
|
|
}
|
|
if !strings.Contains(src[iCall:end], ".catch") {
|
|
t.Error("a failed /api/ui-inject would reject before refresh(\"status\"), " +
|
|
"leaving the dashboard blank")
|
|
}
|
|
}
|