refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义

按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑
全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把
profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices`
侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。

## 变更
- **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试);
- 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出
  `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL);
- `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient,
  半成品规则可保存、可报告);
- `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、
  free/subscription/unpriced/token、USD/M→per-token、peak 窗口、
  long_context 边际价、reasoning 可选价、default 策略、未匹配清单。
  错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。

## 与 PR #2(valuelesser)合并后的语义
- **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见),
  不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口;
- `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记;
  `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键;
- 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。

## 判据(原 Go 单测平移到 Lua + PR #2 的用例)
- `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、
  任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、
  reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、
  lenient 未匹配清单、硬错误 5 例、default 两态;
- `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例
  (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e;
- `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级;
- `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
This commit is contained in:
root
2026-10-20 18:33:26 +08:00
parent 442fa5e3f6
commit b4c5f0f0fc
10 changed files with 819 additions and 765 deletions

View File

@ -0,0 +1,402 @@
package lua
import (
"fmt"
"strings"
"testing"
)
// 规则 → 价格表的编译现在**在插件里**(GitCode issue #1 的建议方向:内核只提供
// 钩子/接口,计费语义下沉到 Lua)。这些判据从原 internal/billing 的 Go 单测
// 平移而来,并补上 issue #1 暴露的两个形状陷阱。
//
// 为什么必须保留这些判据:语义一旦分裂回两处,就会出现"源层零价覆盖模型价"
// 这类静默归零 —— 账单加总正常、只是少报,且 unpriced_reqs 也不涨。
func compilePricesForTest(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) map[string]interface{} {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr && fmt.Sprint(e) != "" {
t.Fatalf("编译失败: %v", e)
}
if p, ok := m["prices"].(map[string]interface{}); ok {
return p
}
t.Fatalf("no prices in %#v", m)
return nil
}
// 编译失败通过载荷里的 error 字段回传(内核桥的第二个返回值通道在插件导出上
// 不可靠;{error=...} 是本仓库的通行约定)。返回错误文本供断言。
func compilePricesErr(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) string {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
return err.Error()
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr {
if s := fmt.Sprint(e); s != "" && s != "<nil>" {
return s
}
}
t.Fatalf("预期编译失败,实得 %#v", m)
return ""
}
func srcList(base string) []map[string]interface{} {
return []map[string]interface{}{{"name": "mine", "base_url": base}}
}
// 通配符 token 规则**不能**给源写零价条目:源层覆盖模型层,会把该源所有未列
// 价模型悄悄算成 0(原 Go 测试 TestWildcardTokenRuleDoesNotMarkPaidSources)。
func TestBillingCompileWildcardTokenDoesNotMarkSources(t *testing.T) {
sources := []map[string]interface{}{
{"name": "paid1", "base_url": "https://paid1.example.com/v1"},
{"name": "paid2", "base_url": "https://paid2.example.com/v1"},
{"name": "free1", "base_url": "https://free.example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://free.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
"only-catalogued-model": map[string]interface{}{"prompt": "1.0", "completion": "2.0"},
}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["free1"]; !ok {
t.Error("显式 free 规则的源条目丢了(free 的 0 是语义)")
}
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("通配符 token 规则把 %q 标成已定价/0 价 —— 其未列价模型会算成 0 而不是进入 unpriced", name)
}
}
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("通配符规则没有发布模型价")
}
}
// token 规则**不得**产生源条目(PR #2 的结论,比 issue 正文的 per_request 标记更收口)。
//
// 只写 {per_request:0} 虽然不会清零模型价,但同源上未列入 rule.Models 的模型仍被
// 视为"已定价"、静默计 0、unpriced 计数不涨 —— 仍是静默少报。统一收口后它们
// 落到 unpriced 统计里(可见);真正"边际成本为零"用 free/subscription。
func TestBillingCompileTokenRuleEmitsNoSourceEntry(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
srcs := prices["sources"].(map[string]interface{})
if entry, ok := srcs["mine"]; ok {
t.Errorf("token 规则产生了源条目 %#v:源层会覆盖模型价 / 静默计 0", entry)
}
// per-million → per-token:0.15 USD/M = 1.5e-7
m := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if m["prompt"].(float64) != 1.5e-7 {
t.Errorf("prompt = %v, want 1.5e-7(per-token)", m["prompt"])
}
if m["completion"].(float64) != 6e-7 {
t.Errorf("completion = %v, want 6e-7", m["completion"])
}
}
// 根因直防:token 规则的源条目不得携带 token 价。
// (free/subscription 是唯一合法的"有意零价",由下一个测试单独守住。)
func TestBillingCompileNoSourceEntryEverCarriesTokenPrices(t *testing.T) {
sources := []map[string]interface{}{
{"name": "a", "base_url": "https://a.example/v1"},
{"name": "b", "base_url": "https://b.example/v1"},
{"name": "c", "base_url": "https://c.example/v1"},
{"name": "d", "base_url": "https://d.example/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://a.example/v1", "mode": "token",
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": "1", "completion": "2"}}},
map[string]interface{}{"url": "https://b.example/v1", "mode": "unpriced"},
map[string]interface{}{"url": "*", "mode": "token",
"models": map[string]interface{}{"m2": map[string]interface{}{"prompt": "3", "completion": "4"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
for name, raw := range prices["sources"].(map[string]interface{}) {
entry, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("源 %q 条目不是表: %#v", name, raw)
}
if _, bad := entry["prompt"]; bad {
t.Errorf("源 %q 携带 prompt=%v:会覆盖该源所有模型价", name, entry["prompt"])
}
if _, bad := entry["completion"]; bad {
t.Errorf("源 %q 携带 completion=%v:会覆盖该源所有模型价", name, entry["completion"])
}
}
}
// free / subscription 的零价是**语义**,必须保留 prompt/completion=0。
func TestBillingCompileExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
for _, mode := range []string{"free", "subscription"} {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": mode, "subscription": 10,
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
for _, key := range []string{"prompt", "completion"} {
if v, present := entry[key]; !present || v != 0.0 {
t.Errorf("%s: entry[%s] = %v (present=%v), want 显式 0", mode, key, v, present)
}
}
}
}
// reasoning 未配置时必须**缺席**(不是 0)。写 0 等于"思考免费",会静默少报
// 所有把 reasoning 按输出计价的 provider。
func TestBillingCompileReasoningPriceOmittedUnlessSet(t *testing.T) {
base := map[string]interface{}{"prompt": "1.0", "completion": "2.0"}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"plain": base,
"reasoning": map[string]interface{}{"prompt": "1.0", "completion": "2.0", "reasoning": "3.0"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
models := prices["models"].(map[string]interface{})
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
t.Error("未配置 reasoning 的模型不应出现 reasoning_price(0 会被当成免费)")
}
rp := models["reasoning"].(map[string]interface{})["reasoning_price"].(float64)
if rp != 3e-6 {
t.Errorf("reasoning_price = %v, want 3e-6", rp)
}
_ = base
}
// long_context:绝对值优先;只给倍数时按基础价的倍数;两者都缺要报错。
func TestBillingCompileLongContextRates(t *testing.T) {
cases := []struct {
name string
lc map[string]interface{}
want float64
wantErr bool
}{
{"absolute", map[string]interface{}{"above": 128000, "prompt": "0.30"}, 3e-7, false},
{"multiplier", map[string]interface{}{"above": 128000, "multiplier": 2}, 3e-7, false},
{"both-absolute-wins", map[string]interface{}{"above": 128000, "prompt": "0.30", "multiplier": 5}, 3e-7, false},
{"neither", map[string]interface{}{"above": 128000}, 0, true},
{"above-zero", map[string]interface{}{"above": 0, "multiplier": 2}, 0, true},
}
for _, tc := range cases {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.6",
"long_context": tc.lc},
},
}},
}
if tc.wantErr {
compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false)
continue
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
lc := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["long_context"].(map[string]interface{})
if lc["prompt"].(float64) != tc.want {
t.Errorf("%s: long_context.prompt = %v, want %v", tc.name, lc["prompt"], tc.want)
}
if lc["above"].(float64) != 128000 {
t.Errorf("%s: above = %v", tc.name, lc["above"])
}
}
}
// peak 的形状是插件唯一读取的 {multiplier, windows=[{days,hours}]}。
func TestBillingCompilePeakShape(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}},
"peak": map[string]interface{}{"multiplier": 2,
"weekdays": []interface{}{1, 2}, "hours": []interface{}{[]interface{}{1, 4}}},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
pk := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["peak"].(map[string]interface{})
if pk["multiplier"].(float64) != 2 {
t.Errorf("multiplier = %v", pk["multiplier"])
}
wins, ok := pk["windows"].([]interface{})
if !ok || len(wins) != 1 {
t.Fatalf("windows = %#v", pk["windows"])
}
win := wins[0].(map[string]interface{})
if len(win["days"].([]interface{})) != 2 || len(win["hours"].([]interface{})) != 1 {
t.Errorf("window = %#v", win)
}
}
// URL 归一:尾斜杠与大小写不应影响匹配;首条命中优先。
func TestBillingCompileURLMatchingAndFirstWins(t *testing.T) {
sources := []map[string]interface{}{
{"name": "mine", "base_url": "https://Mine.Example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1/", "mode": "free"},
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
if entry["prompt"].(float64) != 0 {
t.Errorf("首条规则(free)应优先,实得 %#v", entry)
}
if _, ok := prices["models"].(map[string]interface{})["m"]; ok {
t.Error("第二条规则不应再被应用(首条命中优先,避免顺序歧义)")
}
}
// 严格模式:无源匹配的 URL 是硬错误;lenient 模式跳过并回报具体 URL。
func TestBillingCompileUnmatchedRules(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "https://stale.example.com/v1", "mode": "free"},
},
}
if msg := compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false); msg == "" {
t.Fatal("严格模式必须报错")
}
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": srcList("https://mine.example.com/v1"), "lenient": true,
})
if err != nil {
t.Fatalf("lenient: %v", err)
}
m := out.(map[string]interface{})
rules := m["unmatched_rules"].([]interface{})
if len(rules) != 1 || rules[0] != "https://stale.example.com/v1" {
t.Errorf("unmatched_rules = %#v", rules)
}
prices := m["prices"].(map[string]interface{})
if _, ok := prices["sources"].(map[string]interface{})["mine"]; !ok {
t.Error("lenient 模式下有效规则仍须生效")
}
}
// 硬错误:未知 mode、token 无模型、负价、非数字、profile 不是表。
func TestBillingCompileHardErrors(t *testing.T) {
base := func(rule map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"id": "p", "rules": []interface{}{rule}}
}
url := "https://mine.example.com/v1"
src := srcList(url)
cases := []struct {
name string
profile map[string]interface{}
wantSub string
}{
{"unknown mode", base(map[string]interface{}{"url": url, "mode": "wat"}), "unknown mode"},
{"token no models", base(map[string]interface{}{"url": url, "mode": "token"}), "requires at least one model"},
{"negative price", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "-1", "completion": "1"}}}), "negative price"},
{"not a number", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "abc", "completion": "1"}}}), "not a number"},
{"profile not table", nil, "no billing profile"},
}
for _, tc := range cases {
msg := compilePricesErr(t, tc.profile, src, false)
if !strings.Contains(msg, tc.wantSub) {
t.Errorf("%s: error = %q, want substring %q", tc.name, msg, tc.wantSub)
}
}
}
// profile.default 是"没有规则命中的源"的策略,以前被校验后写进无人读的
// default_mode 键,运行时行为与 unpriced 完全一样(PR #2 修复)。
func TestBillingCompileProfileDefault(t *testing.T) {
sources := []map[string]interface{}{
{"name": "listed", "base_url": "https://listed.example/v1"},
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
}
tokenRule := map[string]interface{}{
"url": "https://listed.example/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
}
// default: free ⇒ 未匹配源写 {per_request:0}(只标记、不碰 token 价)
profile := map[string]interface{}{
"id": "p", "default": "free",
"rules": []interface{}{tokenRule},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
un, ok := srcs["unlisted"].(map[string]interface{})
if !ok {
t.Fatalf("default: free 没有标记未匹配源: %#v", srcs)
}
if _, bad := un["prompt"]; bad {
t.Errorf("default-free 标记携带 prompt=%v:会清零该源模型价", un["prompt"])
}
if _, ok := srcs["listed"]; ok {
t.Error("default: free 不应标记已被规则命中的源")
}
if _, ok := prices["models"].(map[string]interface{})["m"]; !ok {
t.Error("token 规则的模型价丢了")
}
// default: unpriced / 缺省 ⇒ 不写任何标记(让 unpriced 计数看得见)
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{}}
if def != nil {
profile["default"] = def
}
prices := compilePricesForTest(t, profile, sources, false)
if n := len(prices["sources"].(map[string]interface{})); n != 0 {
t.Errorf("default=%v 标记了 %d 个源;它们应保持未定价以便计数", def, n)
}
if _, present := prices["default_mode"]; present {
t.Error("default_mode 是无人读取的死键,不应再写入")
}
}
}

View File

@ -1318,40 +1318,51 @@ func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
}
}
// profile 级 default(compile.go 写入的 default_mode)必须真的生效。
//
// 真实缺陷(GitCode issue #1 顺带发现):compile.go:54 写 `default_mode`、
// :104 写 `unmatched_sources`,但全仓库没有任何读取方 —— 于是 profile 的
// `default: free|unpriced` 在运行时完全等价,区别只存在于 YAML 里。
// "free" 是**刻意定价为 0**,不该进未定价统计;"unpriced" 才该。
func TestBillingProfileDefaultMode(t *testing.T) {
cases := []struct {
mode string
wantUnpriced float64
}{
{"free", 0},
{"unpriced", 1},
{"", 1}, // 未声明 = 现有行为(未定价债务)
// profile 级 default 策略必须真的生效 —— 且它是**编译期**语义(PR #2):
// `default: free` 由 compile_prices 给未匹配的源写 {per_request:0} 标记;
// `unpriced`/缺省不写。旧实现把 default 写进没人读的 default_mode 键,
// 两种取值运行时完全等价。
func TestBillingProfileDefaultIsDecidedAtCompileTime(t *testing.T) {
sources := []map[string]interface{}{
{"name": "listed", "base_url": "https://listed.example/v1"},
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
}
for _, tc := range cases {
tokenRule := map[string]interface{}{
"url": "https://listed.example/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
}
run := func(t *testing.T, profile map[string]interface{}) float64 {
t.Helper()
ps, _ := billingVM(t)
prices := map[string]interface{}{
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": false,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
if tc.mode != "" {
prices["default_mode"] = tc.mode
m := out.(map[string]interface{})
if e, bad := m["error"]; bad && fmt.Sprint(e) != "" {
t.Fatalf("compile: %v", e)
}
_ = ps.SetState("billing", map[string]interface{}{"prices": prices})
_ = ps.SetState("billing", map[string]interface{}{"prices": m["prices"]})
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "no-rule-model", "source": "no-rule-source", "key": "***p", "ok": true,
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
})
st := stateOf(t, ps)
if got := st["unpriced_reqs"].(float64); got != tc.wantUnpriced {
t.Errorf("default_mode=%q: unpriced_reqs = %v, want %v", tc.mode, got, tc.wantUnpriced)
return stateOf(t, ps)["unpriced_reqs"].(float64)
}
free := map[string]interface{}{"id": "p", "default": "free", "rules": []interface{}{tokenRule}}
if got := run(t, free); got != 0 {
t.Errorf("default=free: unpriced_reqs = %v, want 0(刻意免费,不该计入债务)", got)
}
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{tokenRule}}
if def != nil {
profile["default"] = def
}
if got := run(t, profile); got != 1 {
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
}
// 无论哪种模式,成本都是 0;区别只在"是否算未定价债务"。
approx(t, "default_mode="+tc.mode+" cost",
st["total"].(map[string]interface{})["cost"].(float64), 0)
}
}

View File

@ -72,6 +72,228 @@ local DEFAULT_PRICES = {
}
plugin.prices = DEFAULT_PRICES
-- ===== 规则 → 价格表编译(Lua 侧,内核只给数据) ======================
--
-- 为什么编译在插件里(GitCode issue #1 的建议方向):
-- 内核只负责把 config.yaml 解成结构体(YAML 解析 + schema 校验),并把
-- profile 与源的 base_url 作为**数据**通过 plugin.compile_prices 传进插件;
-- "规则如何变成价目表"(free/subscription/unpriced/token、URL 归一匹配、
-- 首条命中优先、token 价的 per-million → per-token、peak 窗口、
-- long_context 边际价、reasoning 可选价、未匹配规则/源)全部在这里。
--
-- 这样内核不需要认识任何计费语义,也不会再出现"两处各写一半、组合起来
-- 静默归零"——issue #1 的真因正是 compile.go 与 priceFor() 各有一套覆盖语义。
--
-- 返回形态(供内核 SetState 注入 plugin.prices):
-- { prices = <价目表>, unmatched_rules = {...}, unmatched_sources = {...} }
-- 硬错误用 `return nil, "原因"`,内核据此拒绝保存/记录 ERROR。
local function normalizeURL(u)
u = tostring(u or ""):gsub("^%s+", ""):gsub("%s+$", "")
u = u:gsub("/+$", "")
return u:lower()
end
local function matchSources(pattern, sources)
local out = {}
if pattern == "*" then
for _, s in ipairs(sources or {}) do out[#out + 1] = s.name end
return out
end
local want = normalizeURL(pattern)
for _, s in ipairs(sources or {}) do
if normalizeURL(s.base_url) == want then out[#out + 1] = s.name end
end
return out
end
-- USD per million → USD per token. 与内核校验用的是同一口径(/1e6),
-- 数值范围与 Go 的 ParseFloatUSDPerM 一致(不允许负数/非数字)。
local function usdPerM(v)
local s = tostring(v == nil and "" or v)
s = s:gsub("^%s+", ""):gsub("%s+$", "")
local n = tonumber(s)
if n == nil then return nil, "not a number: \"" .. tostring(v) .. "\"" end
if n < 0 then return nil, "negative price: \"" .. tostring(v) .. "\"" end
return n / 1e6, nil
end
-- peak 窗口的**唯一**合法形状:{ multiplier, windows = { { days, hours } } }。
-- 早期部署过的 {start,end,weekdays} 没有任何读取方(peak 静默按非高峰计价)。
local function compilePeak(pk)
local win = {}
if type(pk.weekdays) == "table" and #pk.weekdays > 0 then
local days = {}
for i, d in ipairs(pk.weekdays) do days[i] = d end
win.days = days
end
if type(pk.hours) == "table" and #pk.hours > 0 then
local hours = {}
for i, h in ipairs(pk.hours) do hours[i] = { h[1], h[2] } end
win.hours = hours
end
local out = { multiplier = tonumber(pk.multiplier) or 1 }
if next(win) ~= nil then out.windows = { win } end
return out
end
-- long_context 的边际价:绝对值(USD/M 字符串)优先,其次倍数 × 基础价。
-- 两者都缺 ⇒ 报错(一个"没有价格的阶梯"是静默 no-op,写它的人一定有意图)。
local function longContextRate(lc, basePerToken)
local prompt = tostring(lc.prompt == nil and "" or lc.prompt)
prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
local mult = tonumber(lc.multiplier) or 0
if prompt ~= "" then
local v, err = usdPerM(prompt)
if not v then return nil, err end
return v, nil
end
if mult > 0 then return basePerToken * mult, nil end
return nil, "needs either prompt or multiplier"
end
local function applyRule(prices, rule, srcName)
local mode = rule.mode
if mode == "free" or mode == "subscription" then
-- 显式零价:这里 0 本身就是语义(不是"未定价")。
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
if mode == "subscription" then
if prices.subscriptions == nil then prices.subscriptions = {} end
local cur = tostring(rule.currency == nil and "" or rule.currency)
if cur == "" then cur = "USD" end
prices.subscriptions[srcName] = {
monthly = tonumber(rule.subscription) or 0, currency = cur,
}
end
return true
end
if mode == "unpriced" then
-- 刻意不写源条目:让 unpriced_reqs/unpriced_models 抓住它。
return true
end
if mode == "token" then
if type(rule.models) ~= "table" or next(rule.models) == nil then
return nil, string.format("rule for url %q: mode token requires at least one model",
tostring(rule.url))
end
for modelID, t in pairs(rule.models) do
local prompt, perr = usdPerM(t.prompt)
if not prompt then return nil, string.format("model %q prompt: %s", tostring(modelID), perr) end
local completion, cerr = usdPerM(t.completion)
if not completion then return nil, string.format("model %q completion: %s", tostring(modelID), cerr) end
local entry = { prompt = prompt, completion = completion }
if t.cache_discount ~= nil then entry.cache_discount = tonumber(t.cache_discount) end
-- reasoning 可选,且**缺席**而非 0:写 0 等于"思考免费",会静默少报
-- 每一个把 reasoning 按输出计价的 provider。
local reasoning = tostring(t.reasoning == nil and "" or t.reasoning)
reasoning = reasoning:gsub("^%s+", ""):gsub("%s+$", "")
if reasoning ~= "" then
local rp, rerr = usdPerM(reasoning)
if not rp then return nil, string.format("model %q reasoning: %s", tostring(modelID), rerr) end
entry.reasoning_price = rp
end
if type(t.long_context) == "table" then
local above = tonumber(t.long_context.above) or 0
if above <= 0 then
return nil, string.format("model %q long_context: above must be > 0", tostring(modelID))
end
local rate, lerr = longContextRate(t.long_context, prompt)
if not rate then
return nil, string.format("model %q long_context: %s", tostring(modelID), lerr)
end
entry.long_context = { above = above, prompt = rate }
end
if type(rule.peak) == "table" then entry.peak = compilePeak(rule.peak) end
prices.models[modelID] = entry
end
-- ★ token 规则**不写任何源条目**(模型维度只发布模型价)。
--
-- 此前写 `{per_request:0}` 或 `{prompt:0,completion:0}` 都是为了"标记该源
-- 已定价",但 priceFor() 让源层**无条件覆盖**模型层的 prompt/completion:
-- * `{prompt:0,completion:0}` ⇒ 规则刚发布的模型价被清零(issue #1 本体);
-- * 只写 `{per_request:0}` ⇒ 模型价没被清零,但同源上**未列入 rule.Models**
-- 的模型仍被评为"已定价"、静默计 0,unpriced 计数依旧不涨。
-- 第二条仍然是"静默少报",只是窄一点。所以统一收口:token 规则不产生源条目,
-- 未列价的模型落到 unpriced 统计里(可见)。真正"边际成本为零"用
-- mode=free / mode=subscription —— 它们写的是有意为之的零价标记。
return true
end
return nil, string.format("unknown mode %q", tostring(mode))
end
local function compilePrices(profile, sources, lenient)
if type(profile) ~= "table" then return nil, "no billing profile" end
sources = sources or {}
local prices = {
currency = "USD",
default = { prompt = 0, completion = 0, per_request = 0 },
sources = {}, models = {}, keys = {},
}
local rules = type(profile.rules) == "table" and profile.rules or {}
-- currency 是 profile 级声明:第一条带 currency 的规则生效。
for i = 1, #rules do
local c = rules[i].currency
if type(c) == "string" and c ~= "" then prices.currency = c break end
end
local matched = {}
local unmatched_rules = {}
for i = 1, #rules do
local rule = rules[i]
local url = tostring(rule.url == nil and "" or rule.url)
local targets = matchSources(url, sources)
if #targets == 0 and url ~= "*" then
if lenient then
unmatched_rules[#unmatched_rules + 1] = url
else
return nil, string.format("rule url %q matches no configured source base_url", url)
end
else
for _, srcName in ipairs(targets) do
-- 首条命中优先:两条规则匹配同一个源是歧义,后面的不能悄悄覆盖前面的。
if not matched[srcName] then
matched[srcName] = true
local ok, err = applyRule(prices, rule, srcName)
if not ok then return nil, err end
end
end
end
end
-- profile 的 default 策略:只影响**没有规则命中**的源。
--
-- `free` = 运营声明"其余源一律免费" ⇒ 必须被识别为已定价,否则它们会一直计入
-- unpriced,告警变成噪音;标记用 `{per_request:0}`(只标记、不碰 token 价,
-- 否则任何通配符价目表都会被它清零,等于同一个 bug 换个入口复现)。
-- `unpriced`(或缺省)= 不写条目,让插件的 unpriced 计数看得见它们。
--
-- 这个策略以前只被校验、却被写进一个没人读的 `default_mode` 键 ⇒
-- `default: free` 与 `default: unpriced` 运行时完全等价。
if profile.default == "free" then
for _, src in ipairs(sources) do
if not matched[src.name] and prices.sources[src.name] == nil then
prices.sources[src.name] = { per_request = 0 }
end
end
end
local unmatched_sources = {}
for _, src in ipairs(sources) do
if not matched[src.name] then unmatched_sources[#unmatched_sources + 1] = src.name end
end
return { prices = prices, unmatched_rules = unmatched_rules, unmatched_sources = unmatched_sources }
end
-- 内核入口:profile 与源的 base_url 都是**数据**,插件不认识 config.yaml。
plugin.compile_prices = function(args)
args = args or {}
local out, err = compilePrices(args.profile, args.sources, args.lenient and true or false)
-- 错误用**表里的 error 字段**回传,而不是第二个返回值:内核桥的
-- `Call` 只把第一个返回值解码成数据(第二个返回值的错误通道在插件导出上
-- 并不可靠),用 {error=...} 是本仓库其它插件导出一致的约定。
if not out then return { error = tostring(err) } end
return out
end
plugin.exports = plugin.exports or {}
plugin.exports[#plugin.exports + 1] = "compile_prices"
-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh
-- token, which is what DeepSeek/Qwen/Kimi and most others charge. It can be
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
@ -241,17 +463,6 @@ local function priceFor(payload)
if kp.peak ~= nil then out.peak = kp.peak end
end
-- profile-level default for sources no rule matched.
--
-- compile.go has always written `default_mode` ("free" | "unpriced") into the
-- prices table, but NOTHING read it — so `default: free` and
-- `default: unpriced` behaved identically at runtime (both fell through to
-- prices.default's zeros and counted as unpriced debt). That is a second
-- silent under-report (GitCode issue #1 follow-up). "free" is a DELIBERATE
-- zero, so it must be marked priced; anything else stays countable debt.
if not out.priced and p.default_mode == "free" then
out.priced = true
end
return out
end