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
297 lines
10 KiB
Go
297 lines
10 KiB
Go
package lua
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
|
// a wrong price silently produces wrong money. These tests drive it through the
|
|
// real hook path and check the NUMBERS, not merely that it loads.
|
|
|
|
func billingVM(t *testing.T) (*Plugins, string) {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "adapters")
|
|
vm := NewVM(dir)
|
|
if err := vm.Start(); err != nil {
|
|
t.Fatalf("vm: %v", err)
|
|
}
|
|
t.Cleanup(vm.Stop)
|
|
pdir := filepath.Join(t.TempDir(), "plugins")
|
|
ps := NewPlugins(vm, pdir)
|
|
if err := ps.SeedBundled(); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := ps.LoadDir(); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
return ps, pdir
|
|
}
|
|
|
|
// stateOf reads the plugin's published state as a generic map.
|
|
func stateOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
|
t.Helper()
|
|
raw := ps.State("billing")
|
|
if raw == nil {
|
|
t.Fatal("billing published no state")
|
|
}
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out map[string]interface{}
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func approx(t *testing.T, name string, got, want float64) {
|
|
t.Helper()
|
|
d := got - want
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
if d > 1e-9 {
|
|
t.Errorf("%s = %v, want %v (delta %v)", name, got, want, d)
|
|
}
|
|
}
|
|
|
|
// TestBillingZeroPricesIsSafe: with no configuration the plugin must still run
|
|
// and report volume. A nil-price crash here would take out every request.
|
|
func TestBillingZeroPricesIsSafe(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
|
"prompt_tokens": 100, "completion_tokens": 50, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
total := st["total"].(map[string]interface{})
|
|
if total["requests"].(float64) != 1 {
|
|
t.Errorf("requests = %v, want 1", total["requests"])
|
|
}
|
|
approx(t, "cost with no prices", total["cost"].(float64), 0)
|
|
}
|
|
|
|
// TestBillingModelTokenPricing: the core case. prompt and completion are priced
|
|
// SEPARATELY, which is how providers publish and how the total must come out.
|
|
func TestBillingModelTokenPricing(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
// Setting prices must NOT disturb the (still empty) totals, which is the
|
|
// whole point of the prices/state split.
|
|
if err := ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"currency": "USD",
|
|
"models": map[string]interface{}{
|
|
"gpt-5.4": map[string]interface{}{"prompt": 1.25e-6, "completion": 1e-5},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 1000 prompt * 1.25e-6 = 0.00125 ; 500 completion * 1e-5 = 0.005
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "gpt-5.4", "source": "up", "key": "***aaaaaa", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 500, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
approx(t, "total cost", st["total"].(map[string]interface{})["cost"].(float64), 0.00625)
|
|
byModel := st["by_model"].(map[string]interface{})["gpt-5.4"].(map[string]interface{})
|
|
approx(t, "model cost", byModel["cost"].(float64), 0.00625)
|
|
if byModel["completion_tokens"].(float64) != 500 {
|
|
t.Errorf("completion_tokens = %v, want 500", byModel["completion_tokens"])
|
|
}
|
|
}
|
|
|
|
// TestBillingPerRequestAndTokenCombine: a flat fee is ADDED to the token cost,
|
|
// which is how an image model can be "tokens + fixed fee".
|
|
func TestBillingPerRequestAndTokenCombine(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
if err := ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"models": map[string]interface{}{
|
|
"kolors": map[string]interface{}{"prompt": 1e-6, "completion": 2e-6, "per_request": 0.04},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 100*1e-6 + 50*2e-6 + 0.04 = 0.0402
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "kolors", "source": "sf", "key": "***bbbbbb", "ok": true,
|
|
"prompt_tokens": 100, "completion_tokens": 50, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
approx(t, "total", st["total"].(map[string]interface{})["cost"].(float64), 0.0402)
|
|
}
|
|
|
|
// TestBillingPrecedence: keys > models > default for token prices.
|
|
func TestBillingPrecedence(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
_ = ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"default": map[string]interface{}{"prompt": 9e-6, "completion": 9e-6},
|
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 2e-6, "completion": 3e-6}},
|
|
"keys": map[string]interface{}{"***cccccc": map[string]interface{}{"prompt": 1e-6, "completion": 1.5e-6}},
|
|
},
|
|
})
|
|
|
|
// No key match -> model price.
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***other", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
|
})
|
|
// Key match -> key price wins.
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***cccccc", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
// 1000*2e-6 + 1000*3e-6 = 0.005 ; 1000*1e-6 + 1000*1.5e-6 = 0.0025
|
|
approx(t, "total (model + key)", st["total"].(map[string]interface{})["cost"].(float64), 0.0075)
|
|
|
|
// An unpriced model falls back to default.
|
|
ps2, _ := billingVM(t)
|
|
_ = ps2.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"default": map[string]interface{}{"prompt": 9e-6, "completion": 9e-6},
|
|
},
|
|
})
|
|
ps2.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "unknown", "source": "s", "key": "***d", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
|
})
|
|
approx(t, "default fallback", stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64), 0.018)
|
|
}
|
|
|
|
// TestBillingAggregatesEveryDimension: one request must land in all four
|
|
// rollups plus the daily bucket. A missing dimension is the kind of bug a
|
|
// dashboard hides (it just renders an empty table).
|
|
func TestBillingAggregatesEveryDimension(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
_ = ps.SetState("billing", map[string]interface{}{
|
|
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6}},
|
|
})
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m1", "source": "srcA", "key": "***key01", "ok": true,
|
|
"prompt_tokens": 100, "completion_tokens": 100, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
for _, dim := range []string{"by_source", "by_model", "by_key", "by_day"} {
|
|
m, ok := st[dim].(map[string]interface{})
|
|
if !ok || len(m) == 0 {
|
|
t.Errorf("%s is empty; a dimension is missing", dim)
|
|
}
|
|
}
|
|
if _, ok := st["by_source"].(map[string]interface{})["srcA"]; !ok {
|
|
t.Error("by_source lacks srcA")
|
|
}
|
|
if _, ok := st["by_key"].(map[string]interface{})["***key01"]; !ok {
|
|
t.Error("by_key lacks the gateway key")
|
|
}
|
|
// Milliseconds must be converted, not used as seconds: a raw 1750000000000
|
|
// would land in a year-57000 bucket.
|
|
days := st["by_day"].(map[string]interface{})
|
|
found := false
|
|
for k := range days {
|
|
if len(k) == 10 && strings.Contains(k, "-") {
|
|
found = true
|
|
}
|
|
if strings.HasPrefix(k, "5") && len(k) > 6 {
|
|
t.Errorf("by_day key %q suggests millisecond timestamps were not converted", k)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("by_day has no YYYY-MM-DD key: %v", days)
|
|
}
|
|
}
|
|
|
|
// TestBillingFailedRequestPolicy: a failed request keeps its token cost (tokens
|
|
// really were consumed) but drops the flat per_request fee (never charged).
|
|
func TestBillingFailedRequestPolicy(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
_ = ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"models": map[string]interface{}{
|
|
"m": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6, "per_request": 0.5},
|
|
},
|
|
},
|
|
})
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***e", "ok": false, "status": 500,
|
|
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
|
})
|
|
st := stateOf(t, ps)
|
|
// 1000*1e-6 = 0.001, flat dropped.
|
|
approx(t, "failed request", st["total"].(map[string]interface{})["cost"].(float64), 0.001)
|
|
if st["total"].(map[string]interface{})["failures"].(float64) != 1 {
|
|
t.Error("failures not counted")
|
|
}
|
|
}
|
|
|
|
// TestBillingStateAPIReplace: the admin price update must actually change
|
|
// subsequent pricing (not just be stored).
|
|
func TestBillingStateAPIReplace(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
_ = ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 1e-6, "completion": 0}},
|
|
},
|
|
})
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***f", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
|
})
|
|
approx(t, "before reprice", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 0.001)
|
|
|
|
_ = ps.SetState("billing", map[string]interface{}{
|
|
"prices": map[string]interface{}{
|
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 2e-6, "completion": 0}},
|
|
},
|
|
})
|
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
|
"model": "m", "source": "s", "key": "***f", "ok": true,
|
|
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
|
})
|
|
// 0.001 (old) + 0.002 (new price)
|
|
approx(t, "after reprice", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 0.003)
|
|
}
|
|
|
|
// TestBillingPluginDeclaresUI: the shipped plugin must ship its dashboard, or
|
|
// "billing is enabled" would be true while showing the user nothing.
|
|
func TestBillingPluginDeclaresUI(t *testing.T) {
|
|
ps, _ := billingVM(t)
|
|
for _, row := range ps.List() {
|
|
if row["name"] != "billing" {
|
|
continue
|
|
}
|
|
ui, ok := row["ui"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("billing declares no ui")
|
|
}
|
|
if page, _ := ui["page"].(string); page != "billing" {
|
|
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
|
|
}
|
|
if n, _ := ui["elements"].(int); n < 1 {
|
|
t.Error("billing contributes no element to an existing page")
|
|
}
|
|
return
|
|
}
|
|
t.Fatal("billing plugin is not loaded")
|
|
}
|
|
|
|
// TestBillingPluginLoadedByDefault: the shipped plugin must load with no
|
|
// configuration, since seeding only happens on a fresh plugin dir.
|
|
func TestBillingPluginLoadedByDefault(t *testing.T) {
|
|
ps, pdir := billingVM(t)
|
|
if ps.Count() != 1 {
|
|
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
|
|
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
|
|
}
|
|
}
|