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
}

View File

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

View File

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

View File

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