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:
@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -102,6 +103,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugins": ps.List(),
|
||||
"on_disk": ps.OnDisk(),
|
||||
"hook_errors": ps.HookErrors(),
|
||||
"plugin_dir": g.pluginDir(),
|
||||
})
|
||||
@ -125,22 +127,83 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := g.removePlugin(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
|
||||
// Multi-segment paths (…/state) are handled earlier and never reach here.
|
||||
if !strings.Contains(path, "/") {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Reading the SOURCE (not the runtime state) is what an editor
|
||||
// needs; the state endpoint is /state and returns accumulated data
|
||||
// instead. Mixing the two would make a "save what I read"
|
||||
// round-trip impossible.
|
||||
code, err := g.readPluginSource(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"name": path, "code": code,
|
||||
"enabled": g.core.Plugins().Enabled(path),
|
||||
})
|
||||
case http.MethodPut:
|
||||
// Enable / disable. The intent rides in the body rather than the
|
||||
// verb, because "enable" and "disable" are the same resource and
|
||||
// PUT /{name} is the one the docs advertise.
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
|
||||
return
|
||||
}
|
||||
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "name": path, "enabled": *body.Enabled,
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if err := g.removePlugin(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
case http.MethodGet:
|
||||
// A GET on a specific plugin is almost always a client that meant to
|
||||
// delete it but let fetch default to GET; name the verb.
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"plugin source not exposed; use DELETE /api/plugins/"+path+" to remove it, "+
|
||||
"or GET /api/plugins/"+path+"/state for its published state")
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
|
||||
}
|
||||
|
||||
// readPluginSource returns a plugin's file contents for the editor.
|
||||
func (g *Gateway) readPluginSource(name string) (string, error) {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return "", errNoPluginDir
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no plugin source named %q", name)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// validPluginName rejects anything that could escape the plugin directory.
|
||||
// Shared by install / remove / read so the check cannot drift between them.
|
||||
func validPluginName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\.`) {
|
||||
return errPluginName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePluginState serves GET (read state) and PUT (replace state).
|
||||
@ -186,11 +249,8 @@ func (g *Gateway) pluginDir() string {
|
||||
// returned to the caller AND the file is left on disk so the operator can fix
|
||||
// it, matching how adapters behave (the file is authoritative once present).
|
||||
func (g *Gateway) installPlugin(name, code string) error {
|
||||
if name == "" {
|
||||
return errPluginName
|
||||
}
|
||||
if strings.ContainsAny(name, `/\.`) {
|
||||
return errPluginName
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := g.pluginDir()
|
||||
if dir == "" {
|
||||
@ -206,6 +266,9 @@ func (g *Gateway) installPlugin(name, code string) error {
|
||||
}
|
||||
|
||||
func (g *Gateway) removePlugin(name string) error {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return errNoPluginDir
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user