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:
JianFeeeee
2026-10-02 08:33:41 +08:00
parent d0c7465130
commit a78f7cb6c5
4 changed files with 458 additions and 27 deletions

View File

@ -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
}