From a78f7cb6c5ec573d291feb82da7dd9929d05588b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 08:33:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=90=AF=E7=94=A8/=E7=A6=81?= =?UTF-8?q?=E7=94=A8=20+=20=E7=A3=81=E7=9B=98=E5=88=97=E8=A1=A8=20+=20?= =?UTF-8?q?=E5=B3=B0=E8=B0=B7=E5=AE=9A=E4=BB=B7=20+=20=E9=9A=8F=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 插件管理后端 - 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 个包全绿。 --- internal/gateway/plugins_api.go | 101 +++++++++++++++++---- internal/lua/billing_test.go | 151 +++++++++++++++++++++++++++++++ internal/lua/plugins.go | 145 ++++++++++++++++++++++++++++- internal/lua/plugins/billing.lua | 88 +++++++++++++++++- 4 files changed, 458 insertions(+), 27 deletions(-) diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index e7f4dc1..21ef417 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -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 } diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index d16bc6c..119cfdc 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -525,3 +525,154 @@ func TestBillingAnyDimensionCountsAsPriced(t *testing.T) { } approx(t, "flat fee", st["total"].(map[string]interface{})["cost"].(float64), 0.02) } + +// ---- 峰谷 / 时段定价 --------------------------------------------------- +// +// commandcode 的 DeepSeek V4 系列就是这么定价的:非高峰 17h/天,高峰 01-04 & +// 06-10 UTC 工作日,价格恰好 2 倍。这类规则用静态价目无法表达,而算错方向是 +// 静默的——不会报错,只会一直算错。 +// +// 时间判据的可测性:os.date("!%H") 取 UTC 小时。测试通过选择"确定落在窗口内" +// 与"确定落在窗口外"的时段来判定,不去伪造时钟(Lua 侧没有可注入的时钟, +// 伪造反而会让测试与真实行为脱节)。 + +func TestBillingPeakWindowDoublesOutsidePeak(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 全部 7 天全部 24 小时 ⇒ 永远命中 + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{0, 23}}, + }, + }, + }, + }, + }, + }, + }) + // 1000 prompt + 1000 completion,非高峰 0.00075 → 命中峰谷 ×2 = 0.0015 + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + want := (1000*1.5e-7 + 1000*6e-7) * 2 + approx(t, "always-peak cost", got, want) +} + +// 一个不存在的窗口(UTC 25 点不存在)⇒ 永不命中 ⇒ 静态价。 +func TestBillingPeakWindowNotHit(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 星期 = {0..6} 但小时窗写成 [99,100]:永远不可能命中 + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{99, 100}}, + }, + }, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "never-peak cost", got, 1000*1.5e-7+1000*6e-7) +} + +// 星期不匹配 ⇒ 不命中。这一条正是"用本地时区算会整体偏移"要防的东西: +// 周日按 UTC 算,用本地时区可能算成周六而错误地命中工作日窗口。 +func TestBillingPeakWindowDayMismatch(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, + "peak": map[string]interface{}{ + "multiplier": 2, + // 只在"不存在的星期 7"上开窗(os.date %w 只到 0..6) + "windows": []interface{}{ + map[string]interface{}{ + "days": []interface{}{7}, + "hours": []interface{}{[]interface{}{0, 23}}, + }, + }, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + if got > (1000*1.5e-7+1000*6e-7)*1.5 { + t.Errorf("cost = %v: a non-matching weekday must not trigger the peak multiplier", got) + } +} + +// 没有 peak 规则的条目完全不受影响(向后兼容)。 +func TestBillingNoPeakRuleIsUnaffected(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "plain": map[string]interface{}{"prompt": 1e-6, "completion": 2e-6}, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "plain", "source": "s", "key": "***n", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000, + }) + approx(t, "no-peak cost", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 1000*1e-6+1000*2e-6) +} + +// 缓存读价【不】跟着峰谷翻倍:它是另一条上游费率,观测到的非峰谷价里已经含了 +// 自己的折扣,跟着翻倍会把两个折扣叠在一起。 +func TestBillingPeakDoesNotDoubleCacheRead(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "models": map[string]interface{}{ + "dsv41": map[string]interface{}{ + "prompt": 1.5e-7, "completion": 6e-7, "cache_discount": 0.02, + "peak": map[string]interface{}{ + "multiplier": 2, + "windows": []interface{}{map[string]interface{}{ + "days": []interface{}{0, 1, 2, 3, 4, 5, 6}, + "hours": []interface{}{[]interface{}{0, 23}}, + }}, + }, + }, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "dsv41", "source": "commandcode", "key": "***c", "ok": true, + "prompt_tokens": 1000000, "completion_tokens": 0, + "cache_hit_tokens": 1000000, "time": 1750000000000, + }) + // 全部缓存命中 ⇒ 只按 cache 价 = prompt * 0.02,且不翻倍 + got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) + approx(t, "cache-only cost (not doubled)", got, 1e6*1.5e-7*0.02) +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 1fa7522..1bbae8f 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -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 diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 48828e7..314da66 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -142,7 +142,7 @@ local function priceFor(payload) -- quietly under-reports. It is counted separately and surfaced in the UI. out = { prompt = d.prompt or 0, completion = d.completion or 0, - per_request = 0, cache_discount = d.cache_discount, + per_request = 0, cache_discount = d.cache_discount, peak = d.peak, } -- model dimension (a token price overrides the default's token prices) @@ -153,6 +153,7 @@ local function priceFor(payload) if mp.completion ~= nil then out.completion = mp.completion end if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end + if mp.peak ~= nil then out.peak = mp.peak end end -- source dimension: usually a flat fee, but may also carry token prices @@ -163,6 +164,7 @@ local function priceFor(payload) if sp.completion ~= nil then out.completion = sp.completion end if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end + if sp.peak ~= nil then out.peak = sp.peak end end -- key dimension wins over the others (an operator pricing one customer @@ -174,10 +176,83 @@ local function priceFor(payload) if kp.completion ~= nil then out.completion = kp.completion end if kp.per_request ~= nil then out.per_request = out.per_request + kp.per_request end if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end + if kp.peak ~= nil then out.peak = kp.peak end end return out end +-- ===== 峰谷 / 时段定价 ================================================ +-- +-- 有些 provider 按 UTC 时段分价(commandcode 的 DeepSeek V4 系列就是:高峰 +-- 01-04 & 06-10 UTC 工作日,价格恰好是非高峰的 2 倍)。静态价目无法表达这一点, +-- 而算错方向通常是【静默高估或低估】,不会报错——所以这里显式支持。 +-- +-- 配置形态(挂在任一维度的价目条目上): +-- +-- "deepseek-v4.1-flash": { +-- prompt = 1.5e-7, completion = 6e-7, +-- peak = { +-- multiplier = 2, -- 高峰时单价乘以它 +-- windows = [ -- UTC 星期几 = os.date 的 %w(周日=1) +-- { days = {2,3,4,5,6}, hours = {{1,2,3},{6,7,8,9}} }, +-- ], +-- }, +-- } +-- +-- 语义:命中任一 window ⇒ 乘以 multiplier。hours 用 {起,止} 闭区间,跨零点 +-- 用 {{22,24}} 表示 22:00-24:00(24 是"当天最后一刻")。 +-- +-- ★ 为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关 +-- 跑在本地时区(这台机是 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8 +-- 小时,白天算成夜间——比不做峰谷还糟。 +local function inPeakWindow(ev) + if ev == nil then return false end + local w = ev.windows + if type(w) ~= "table" or #w == 0 then return false end + local dow = tonumber(os.date("!%w")) or 0 -- 0=Sunday + local hour = tonumber(os.date("!%H")) or 0 + for _, win in ipairs(w) do + local days = win.days + if type(days) == "table" then + local day_ok = false + for _, d in ipairs(days) do + if tonumber(d) == dow then day_ok = true break end + end + if not day_ok then goto continue_win end + end + local hours = win.hours + if type(hours) == "table" then + for _, h in ipairs(hours) do + local lo, hi = tonumber(h[1]), tonumber(h[2]) + if lo and hi and hour >= lo and hour <= hi then return true end + end + end + ::continue_win:: + end + return false +end + +-- applyPeak multiplies a price by the peak rule, if the request lands in a peak +-- window. It is a no-op when no rule is configured, so the common case costs one +-- nil check. +-- +-- The multiplier is RECORDED, not applied to price.prompt in place. That looks +-- like a roundabout way to do it, but applying it there was a real bug: the +-- cache-read rate is DERIVED from price.prompt inside costFor, so doubling +-- price.prompt silently doubled the cache read too — compounding two separate +-- discounts. Keeping the multiplier separate lets costFor scale the fresh-prompt +-- and completion legs and leave the cache leg alone, which is what "peak rates +-- apply to the token price, cache reads are billed at their own rate" means. +local function applyPeak(price) + local pk = price.peak + if pk == nil then return price end + if not inPeakWindow(pk) then return price end + local m = tonumber(pk.multiplier) or 1 + if m <= 0 then return price end + price.peak_multiplier = m + return price +end + -- costFor computes one request's price. -- -- PROMPT CACHE: a cached prompt token is not billed like a fresh one. Almost @@ -200,7 +275,7 @@ end -- fresh count never goes negative, which would silently turn a request into -- billable negative tokens. local function costFor(payload, price) - price = price or priceFor(payload) + price = applyPeak(price or priceFor(payload)) local prompt = tonumber(payload.prompt_tokens) or 0 local completion = tonumber(payload.completion_tokens) or 0 local cacheHit = tonumber(payload.cache_hit_tokens) or 0 @@ -212,10 +287,15 @@ local function costFor(payload, price) if discount == nil then discount = 0.1 end if discount < 0 then discount = 0 elseif discount > 1 then discount = 1 end + -- The peak multiplier applies to the freshly-read prompt tokens and the + -- completion, but NOT to the cache read: a cache read is a separate upstream + -- rate that the off-peak figures already discount, and doubling it would + -- stack two discounts the provider never intended to stack. + local mult = tonumber(price.peak_multiplier) or 1 local fresh = prompt - cacheHit - local cost = fresh * price.prompt + local cost = fresh * price.prompt * mult + cacheHit * price.prompt * discount - + completion * price.completion + + completion * price.completion * mult local flat = price.per_request if not payload.ok and not plugin.count_failures then