mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
feat(plugin): 启用/禁用 + 磁盘列表 + 峰谷定价 + 随核心发布
## 插件管理后端
- PUT /api/plugins/{name} {"enabled":bool} 启用/禁用
- GET /api/plugins/{name} 读源码(编辑器用,与 /state 区分)
- GET /api/plugins 的 on_disk 字段 列出目录里所有 .lua 及其加载态
- validPluginName 提取为共享函数,install/remove/read 三处共用,防止检查漂移
禁用是**运行态开关,不删文件**:插件把线上网关搞坏了、但离修好只差一行时,
运维需要把它移出请求路径而不丢失它(同 systemd mask 而非 remove 的道理)。
它**不跨重启保留**——一个悄悄比操作者意图活得更久的"禁用"本身就是个意外。
Builtin 的判定是「加载的源码与内嵌版本逐字节相同」,而不是「名字匹配」:
被改过的 billing.lua 不能被标成 builtin,否则 UI 会提供覆盖用户改动的操作。
on_disk 列表包含**加载失败**的插件。否则一个语法错误的插件在 UI 上直接消失,
运维看到的现象是"插件不见了"而不是"插件报错了"。
## 峰谷 / 时段定价
commandcode 的 DeepSeek V4 系列就是高峰 01-04 & 06-10 UTC 工作日 2 倍价
(非高峰 17h/天)。静态价目表达不了,而算错方向是**静默**的。
价目条目可带 peak = {multiplier, windows=[{days, hours}]}。命中任一窗口即乘。
★ 用 `os.date("!%H")` 取 **UTC** 小时:provider 费率表按 UTC 标注,而网关跑在
本地时区(本机 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8 小时,
白天算成夜间——比不做峰谷还糟。
## ★ 实现与注释不一致,被判据抓住
applyPeak 最初直接 `price.prompt = price.prompt * m`,注释写「缓存读不翻倍」。
但 costFor 里**缓存读价是从 price.prompt 派生的**,所以原地翻倍会把缓存读
也翻倍——两个折扣被叠在一起,而 provider 从没打算叠。
改成 applyPeak 只**记录**乘数,由 costFor 分段应用:fresh prompt 与 completion
翻倍,cache read 那一项不动。
只靠注释说明意图是不够的:TestBillingPeakDoesNotDoubleCacheRead 立刻红了
(0.006 vs 期望 0.003)。变异回原实现仍是红的。
## 判据(21 个计费测试全绿,新增 5 个峰谷)
窗口恒命中 ×2 / 窗口永不命中保持静态价 / 星期不匹配不命中
(这条正是防"用本地时区整体偏移 8 小时")/ 无 peak 规则向后兼容
/ 缓存读不随峰谷翻倍
后端部分:构建/vet/gofmt 干净,8 个包全绿。
This commit is contained in:
@ -159,9 +159,23 @@ type Plugin struct {
|
||||
// LoadError is non-empty when the plugin failed to compile or register. Such
|
||||
// a plugin is listed in the UI with its error but is never called.
|
||||
LoadError string
|
||||
dir string
|
||||
// Disabled marks a plugin the operator switched off. It stays on disk and
|
||||
// keeps its name, hooks and UI declared (so the UI can show it greyed out
|
||||
// and report what it WOULD contribute), but Fire never calls it and its
|
||||
// UI extension is excluded from the inject payload.
|
||||
//
|
||||
// Disabling is deliberately NOT deleting: a plugin that breaks a live
|
||||
// gateway is often one line away from being fixed, and an operator needs a
|
||||
// way to take it out of the request path without losing it. That is the same
|
||||
// reasoning as a systemd unit being masked rather than removed.
|
||||
Disabled bool
|
||||
dir string
|
||||
// script is the plugin's source, kept so a reload can rebuild its state.
|
||||
script string
|
||||
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
|
||||
// such so an operator can tell "example I can read" from "mine", and a
|
||||
// delete of a builtin is allowed but re-seeds on a fresh plugin dir.
|
||||
Builtin bool
|
||||
|
||||
// state is the plugin's SINGLE authoritative Lua state, guarded by mu.
|
||||
//
|
||||
@ -363,6 +377,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
p.script = code
|
||||
p.state = w
|
||||
// Builtin = the shipped source is byte-identical to what this file was
|
||||
// loaded from. That is stronger than "the name matches a bundled plugin": an
|
||||
// operator who EDITED billing.lua must not be told their copy is builtin,
|
||||
// or the UI would offer to overwrite their changes.
|
||||
if orig, err := ReadBundledPlugin(name); err == nil && orig == code {
|
||||
p.Builtin = true
|
||||
}
|
||||
|
||||
// ---- manifest ----
|
||||
w.L.GetGlobal(pluginGlobal)
|
||||
@ -515,7 +536,10 @@ func (ps *Plugins) rebuild() {
|
||||
defer ps.mu.Unlock()
|
||||
stageFuncs := map[Stage][]hookCall{}
|
||||
for i, p := range ps.plugins {
|
||||
if p.LoadError != "" {
|
||||
// Disabled plugins are excluded from BOTH the dispatch table and the
|
||||
// merged UI below. Including them in the UI would render a page whose
|
||||
// refresh calls go to a plugin that is never consulted.
|
||||
if p.LoadError != "" || p.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, st := range AllStages {
|
||||
@ -528,7 +552,7 @@ func (ps *Plugins) rebuild() {
|
||||
|
||||
merged := &UIExtension{}
|
||||
for _, p := range ps.plugins {
|
||||
if p.LoadError != "" || p.UI == nil {
|
||||
if p.LoadError != "" || p.Disabled || p.UI == nil {
|
||||
continue
|
||||
}
|
||||
if p.UI.Page != nil {
|
||||
@ -539,6 +563,78 @@ func (ps *Plugins) rebuild() {
|
||||
ps.ui.Store(merged)
|
||||
}
|
||||
|
||||
// DiskEntry is one .lua file in the plugin directory, whether or not it loaded.
|
||||
// The management UI lists these rather than only the loaded set, so an operator
|
||||
// can see (and fix) a plugin that failed to compile instead of finding it
|
||||
// missing from the list.
|
||||
type DiskEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Version/Description are read from the loaded plugin when available.
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Hooks int `json:"hooks"`
|
||||
}
|
||||
|
||||
// OnDisk lists every .lua file in the plugin directory with its load state.
|
||||
func (ps *Plugins) OnDisk() []DiskEntry {
|
||||
out := []DiskEntry{}
|
||||
if ps.dir == "" {
|
||||
return out
|
||||
}
|
||||
entries, err := os.ReadDir(ps.dir)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".lua")
|
||||
info, _ := e.Info()
|
||||
de := DiskEntry{Name: name, Path: e.Name()}
|
||||
if info != nil {
|
||||
de.Size = info.Size()
|
||||
}
|
||||
ps.mu.RLock()
|
||||
if p := ps.findLocked(name); p != nil {
|
||||
de.Loaded = p.LoadError == ""
|
||||
de.Disabled = p.Disabled
|
||||
de.Builtin = p.Builtin
|
||||
de.Error = p.LoadError
|
||||
de.Version = p.Info.Version
|
||||
de.Description = p.Info.Description
|
||||
de.Hooks = len(p.Hooks)
|
||||
} else {
|
||||
// On disk but not in the running set: either it failed so badly
|
||||
// that LoadSource never produced a record, or the dir was written
|
||||
// after startup. Mark it by comparing with the bundled source.
|
||||
if code, err := os.ReadFile(filepath.Join(ps.dir, e.Name())); err == nil {
|
||||
if orig, err := ReadBundledPlugin(name); err == nil && orig == string(code) {
|
||||
de.Builtin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
out = append(out, de)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
// builtins first, then alphabetical: the example plugin an operator
|
||||
// is most likely to want to read should not be buried under whatever
|
||||
// they installed most recently.
|
||||
if out[i].Builtin != out[j].Builtin {
|
||||
return out[i].Builtin
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Count returns how many plugins loaded (including ones with LoadError).
|
||||
func (ps *Plugins) Count() int {
|
||||
ps.mu.RLock()
|
||||
@ -565,6 +661,8 @@ func (ps *Plugins) List() []map[string]interface{} {
|
||||
"author": p.Info.Author,
|
||||
"hooks": stages,
|
||||
"loaded": p.LoadError == "",
|
||||
"disabled": p.Disabled,
|
||||
"builtin": p.Builtin,
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
row["error"] = p.LoadError
|
||||
@ -700,6 +798,38 @@ func (ps *Plugins) SetState(name string, state interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEnabled turns a plugin's dispatch on or off without touching its file.
|
||||
//
|
||||
// The state is on the Plugin record (not derived from disk) so a disable survives
|
||||
// as long as the process lives and is trivially re-enabled; it deliberately does
|
||||
// NOT persist across restarts, because a "disable" that silently outlives the
|
||||
// operator's intent is its own surprise. An operator who wants it permanent
|
||||
// moves the file out of the plugin dir.
|
||||
func (ps *Plugins) SetEnabled(name string, enabled bool) error {
|
||||
ps.mu.Lock()
|
||||
p := ps.findLocked(name)
|
||||
ps.mu.Unlock()
|
||||
if p == nil {
|
||||
return fmt.Errorf("plugin %s not loaded", name)
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
return fmt.Errorf("plugin %s failed to load (%s); fix the file before enabling it", name, p.LoadError)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.Disabled = !enabled
|
||||
p.mu.Unlock()
|
||||
ps.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled reports whether a plugin is currently dispatching.
|
||||
func (ps *Plugins) Enabled(name string) bool {
|
||||
ps.mu.RLock()
|
||||
p := ps.findLocked(name)
|
||||
ps.mu.RUnlock()
|
||||
return p != nil && p.LoadError == "" && !p.Disabled
|
||||
}
|
||||
|
||||
// Unload removes a plugin from the running set. Its states are closed so the
|
||||
// memory goes back; a subsequent LoadSource with the same name works again.
|
||||
func (ps *Plugins) Unload(name string) error {
|
||||
@ -728,8 +858,15 @@ func (ps *Plugins) Unload(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// find returns a loaded plugin by declared name. Caller holds ps.mu.
|
||||
// find returns a loaded plugin by declared name, taking the read lock.
|
||||
func (ps *Plugins) find(name string) *Plugin {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.findLocked(name)
|
||||
}
|
||||
|
||||
// findLocked returns a loaded plugin by declared name. The caller holds ps.mu.
|
||||
func (ps *Plugins) findLocked(name string) *Plugin {
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == name {
|
||||
return p
|
||||
|
||||
Reference in New Issue
Block a user