mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持
按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
`billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
(GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
(cherry picked from commit 7941b4110a)
This commit is contained in:
227
internal/lua/billing_rules_test.go
Normal file
227
internal/lua/billing_rules_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// billing 的规则**完全在插件内**:以源名为键、由插件自己编译、自己写
|
||||
// `<plugin_dir>/billing.json`。内核没有 billing 代码,也不提供 billing 端点;
|
||||
// 这些判据直接打插件的导出(对应插件自己的 /api/plugins/http/billing/rules)。
|
||||
func putRules(t *testing.T, ps *Plugins, rules map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules})
|
||||
if err != nil {
|
||||
t.Fatalf("rules_put: %v", err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
return m
|
||||
}
|
||||
|
||||
func pricesOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := ps.Prices("billing")
|
||||
p, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("billing has no prices: %#v", raw)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func oneProfile(rules []interface{}, def interface{}) map[string]interface{} {
|
||||
p := map[string]interface{}{"id": "p", "rules": rules}
|
||||
if def != nil {
|
||||
p["default"] = def
|
||||
}
|
||||
return map[string]interface{}{"active": "p", "profiles": []interface{}{p}}
|
||||
}
|
||||
|
||||
// 插件自己把规则写到 plugin_dir/billing.json,且读取回来一致。
|
||||
func TestBillingRulesPersistInPluginOwnedFile(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "localzen", "mode": "free"},
|
||||
}, "unpriced")
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put 失败: %#v", m)
|
||||
}
|
||||
path := filepath.Join(pdir, "billing.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("插件没有自己写规则文件 %s: %v", path, err)
|
||||
}
|
||||
var onDisk map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &onDisk); err != nil {
|
||||
t.Fatalf("规则文件不是 JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "localzen") {
|
||||
t.Fatalf("规则文件内容不对: %s", raw)
|
||||
}
|
||||
// 重新加载插件(模拟重启):规则应从文件恢复。
|
||||
ps2 := reloadBilling(t, pdir)
|
||||
got, err := ps2.Call("billing", "rules_get", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("rules_get: %v", err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if !strings.Contains(string(b), "localzen") {
|
||||
t.Fatalf("重启后没有从 billing.json 恢复规则: %s", b)
|
||||
}
|
||||
// 编译结果也恢复了:free 源条目存在。
|
||||
if p := ps2.Prices("billing").(map[string]interface{}); p != nil {
|
||||
if _, ok := p["sources"].(map[string]interface{})["localzen"]; !ok {
|
||||
t.Errorf("恢复后价格表缺 localzen: %#v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadBilling(t *testing.T, pdir string) *Plugins {
|
||||
t.Helper()
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pdir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
// 编译语义:free/subscription 写"有意零价",token 规则**不写源条目**(未列价
|
||||
// 的模型要可见地进入 unpriced),通配符只发布模型价。
|
||||
func TestBillingRulesCompileSemantics(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "free-src", "mode": "free"},
|
||||
map[string]interface{}{"source": "sub-src", "mode": "subscription", "subscription": 20},
|
||||
map[string]interface{}{"source": "tok-src", "mode": "token", "models": map[string]interface{}{
|
||||
"m1": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||
}},
|
||||
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
|
||||
"m2": map[string]interface{}{"prompt": "1", "completion": "2"},
|
||||
}},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
p := pricesOf(t, ps)
|
||||
srcs := p["sources"].(map[string]interface{})
|
||||
for _, n := range []string{"free-src", "sub-src"} {
|
||||
e, ok := srcs[n].(map[string]interface{})
|
||||
if !ok || e["prompt"] != 0.0 || e["completion"] != 0.0 {
|
||||
t.Errorf("%s 应写有意零价: %#v", n, srcs[n])
|
||||
}
|
||||
}
|
||||
if _, ok := srcs["tok-src"]; ok {
|
||||
t.Errorf("token 规则不得产生源条目: %#v", srcs["tok-src"])
|
||||
}
|
||||
if subs, ok := p["subscriptions"].(map[string]interface{})["sub-src"].(map[string]interface{}); !ok || subs["monthly"] != 20.0 {
|
||||
t.Errorf("subscription 固定费丢失: %#v", p["subscriptions"])
|
||||
}
|
||||
models := p["models"].(map[string]interface{})
|
||||
if models["m1"].(map[string]interface{})["prompt"] != 1.5e-7 {
|
||||
t.Errorf("m1 prompt = %#v, want 1.5e-7", models["m1"])
|
||||
}
|
||||
if _, ok := models["m2"]; !ok {
|
||||
t.Error("通配符规则的模型价丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:token 规则 + 1e6/1e6 tokens @ 0.15/0.60 USD/M = 0.75,
|
||||
// 且 unpriced 计数不涨(模型本身被标价)。
|
||||
func TestBillingRulesTokenCostEndToEnd(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "tok-src", "mode": "token", "models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||
}},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "tok-src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000000.0, "completion_tokens": 1000000.0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
approx(t, "cost", st["total"].(map[string]interface{})["cost"].(float64), 0.75)
|
||||
if got := st["unpriced_reqs"].(float64); got != 0 {
|
||||
t.Errorf("unpriced_reqs = %v, want 0", got)
|
||||
}
|
||||
// 同源未列价的模型必须落入 unpriced(可见),而不是静默计 0。
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "unlisted", "source": "tok-src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 100.0, "completion_tokens": 1.0, "time": 1750000000000,
|
||||
})
|
||||
if got := stateOf(t, ps)["unpriced_reqs"].(float64); got != 1 {
|
||||
t.Errorf("未列价模型应计入 unpriced,got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法规则必须被拒绝,且**不覆盖**已有文件(保存失败不能丢旧配置)。
|
||||
func TestBillingRulesRejectInvalidWithoutClobberingFile(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
good := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "free"},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, good); m["ok"] != true {
|
||||
t.Fatalf("seed: %#v", m)
|
||||
}
|
||||
path := filepath.Join(pdir, "billing.json")
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
bad := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "nope"},
|
||||
}, nil)
|
||||
out, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": bad})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["error"] == nil {
|
||||
t.Fatalf("非法 mode 应被拒绝: %#v", m)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(before) != string(after) {
|
||||
t.Errorf("被拒绝的保存改动了规则文件:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// reasoning 缺席 = 按输出价计;显式 long_context/peak 必须编译成插件读取的形状。
|
||||
func TestBillingRulesOptionalPricesCompile(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "token",
|
||||
"models": map[string]interface{}{
|
||||
"plain": map[string]interface{}{"prompt": "1", "completion": "2"},
|
||||
"reasoning": map[string]interface{}{"prompt": "1", "completion": "2", "reasoning": "3"},
|
||||
"long": map[string]interface{}{"prompt": "1", "completion": "2",
|
||||
"long_context": map[string]interface{}{"above": 128000, "multiplier": 2}},
|
||||
},
|
||||
"peak": map[string]interface{}{"multiplier": 2, "hours": []interface{}{[]interface{}{1, 4}}},
|
||||
},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
models := pricesOf(t, ps)["models"].(map[string]interface{})
|
||||
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
|
||||
t.Error("未配置 reasoning 不应出现 reasoning_price(0 会被当成免费)")
|
||||
}
|
||||
if got := models["reasoning"].(map[string]interface{})["reasoning_price"]; got != 3e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 3e-6", got)
|
||||
}
|
||||
lc := models["long"].(map[string]interface{})["long_context"].(map[string]interface{})
|
||||
if lc["above"] != 128000.0 || lc["prompt"] != 2e-6 {
|
||||
t.Errorf("long_context = %#v", lc)
|
||||
}
|
||||
pk := models["long"].(map[string]interface{})["peak"].(map[string]interface{})
|
||||
if pk["multiplier"] != 2.0 {
|
||||
t.Errorf("peak = %#v", pk)
|
||||
}
|
||||
}
|
||||
@ -340,7 +340,7 @@ func TestBillingRuleEditorLivesInsideTheBillingPage(t *testing.T) {
|
||||
for _, needle := range []string{
|
||||
`id="br-body"`,
|
||||
"data-act='save'", "data-act='export'", "data-act='newprofile'",
|
||||
".r-url", ".r-mode",
|
||||
".r-src", ".r-mode",
|
||||
} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("the rule editor is missing %s", needle)
|
||||
@ -1236,3 +1236,139 @@ func TestLongContextComposesWithCacheDiscount(t *testing.T) {
|
||||
// total = 0.2325
|
||||
approx(t, "step composes with cache discount", got, 0.2325)
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
// ---- 源层零价覆盖模型价(issue #1)---------------------------------------
|
||||
//
|
||||
// 真实缺陷(GitCode issue #1):`compile.go` 给每条**具体 URL** 的 token 规则
|
||||
// 写一条 `{prompt:0, completion:0, per_request:0}` 的源条目,本意只是"标记该源
|
||||
// 已定价";而插件 priceFor() 的源层是**无条件覆盖**模型层:`sp.prompt ~= nil`
|
||||
// 就把模型价改成 0。两条各自正当的规则叠加 ⇒ 每个请求 cost:0,且因为
|
||||
// out.priced=true,连 unpriced_reqs 都不涨 —— 完全静默。
|
||||
//
|
||||
// 这里把两种条目形状都钉住:旧形状必须**真的是错的**(A 行),新形状必须
|
||||
// **保住模型价同时仍算"已定价"**(B 行)。只断言"键存在"是不够的 —— 那正是
|
||||
// 现有测试把错误形状固化成契约的原因。
|
||||
func TestBillingSourceZeroPriceMustNotZeroModelPrice(t *testing.T) {
|
||||
const promptPrice = 1.5e-7 // 0.15 USD/M
|
||||
const completionPrice = 6e-7 // 0.60 USD/M
|
||||
const want = 0.75 // 1e6 * 1.5e-7 + 1e6 * 6e-7
|
||||
|
||||
newPrices := func(sourceEntry map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": promptPrice, "completion": completionPrice},
|
||||
},
|
||||
"sources": map[string]interface{}{"src": sourceEntry},
|
||||
}
|
||||
}
|
||||
run := func(t *testing.T, sourceEntry map[string]interface{}) (cost, unpriced float64) {
|
||||
t.Helper()
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{"prices": newPrices(sourceEntry)})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000000.0, "completion_tokens": 1000000.0,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
total := st["total"].(map[string]interface{})
|
||||
return total["cost"].(float64), st["unpriced_reqs"].(float64)
|
||||
}
|
||||
|
||||
// A 行:compile.go 修复前的形状 —— 这是缺陷本身,必须算成 0。
|
||||
cost, unpriced := run(t, map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
})
|
||||
approx(t, "旧形状(全零源条目) cost", cost, 0)
|
||||
if unpriced != 0 {
|
||||
t.Errorf("旧形状下 unpriced_reqs = %v,缺陷正是它也为 0(静默)", unpriced)
|
||||
}
|
||||
|
||||
// B 行:修复后的形状 —— 只带 per_request,不得覆盖模型价。
|
||||
cost, unpriced = run(t, map[string]interface{}{"per_request": 0.0})
|
||||
approx(t, "新形状(仅 per_request) cost", cost, want)
|
||||
if unpriced != 0 {
|
||||
t.Errorf("新形状下 unpriced_reqs = %v,want 0(该源仍算已定价)", unpriced)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一源上一个**未列入价目表**的模型:源条目(仅 per_request)让它保持
|
||||
// "已定价",所以不会被计入 unpriced_reqs —— 这正是 compile 写源条目的原意。
|
||||
func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"models": map[string]interface{}{
|
||||
"priced-model": map[string]interface{}{"prompt": 1.5e-7, "completion": 6e-7},
|
||||
},
|
||||
"sources": map[string]interface{}{
|
||||
"src": map[string]interface{}{"per_request": 0.0},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "unlisted-model", "source": "src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000.0, "completion_tokens": 0.0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
if got := st["unpriced_reqs"].(float64); got != 0 {
|
||||
t.Errorf("unpriced_reqs = %v,want 0:源条目存在即为已定价源", got)
|
||||
}
|
||||
}
|
||||
|
||||
// profile 级 default 策略:规则以**源名**为键,编译在插件内完成;
|
||||
// `default: free` 表示"没有单独规则的源一律免费"(不进 unpriced 统计),
|
||||
// `unpriced`/缺省则让这些源保持可统计;显式 mode=unpriced 的源即使
|
||||
// default=free 也必须仍被统计。
|
||||
func TestBillingProfileDefaultPolicy(t *testing.T) {
|
||||
newVM := func(t *testing.T, rules map[string]interface{}) *Plugins {
|
||||
t.Helper()
|
||||
ps, _ := billingVM(t)
|
||||
if _, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules}); err != nil {
|
||||
t.Fatalf("rules_put: %v", err)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
fire := func(t *testing.T, ps *Plugins, source string) float64 {
|
||||
t.Helper()
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "some-unlisted-model", "source": source, "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
||||
})
|
||||
return stateOf(t, ps)["unpriced_reqs"].(float64)
|
||||
}
|
||||
base := func(def interface{}, rules []interface{}) map[string]interface{} {
|
||||
p := map[string]interface{}{"id": "p", "rules": rules}
|
||||
if def != nil {
|
||||
p["default"] = def
|
||||
}
|
||||
return map[string]interface{}{"active": "p", "profiles": []interface{}{p}}
|
||||
}
|
||||
|
||||
// default=free:未列规则的源算"刻意免费"
|
||||
ps := newVM(t, base("free", []interface{}{
|
||||
map[string]interface{}{"source": "priced-src", "mode": "free"},
|
||||
}))
|
||||
if got := fire(t, ps, "unlisted-src"); got != 0 {
|
||||
t.Errorf("default=free: unpriced_reqs = %v, want 0", got)
|
||||
}
|
||||
// default=free 但显式 unpriced 的源仍要统计
|
||||
ps = newVM(t, base("free", []interface{}{
|
||||
map[string]interface{}{"source": "counted-src", "mode": "unpriced"},
|
||||
}))
|
||||
if got := fire(t, ps, "counted-src"); got != 1 {
|
||||
t.Errorf("显式 unpriced 在 default=free 下仍应统计,got %v", got)
|
||||
}
|
||||
// default 缺省/unpriced:未列规则的源计入 unpriced
|
||||
for _, def := range []interface{}{nil, "unpriced"} {
|
||||
ps := newVM(t, base(def, []interface{}{}))
|
||||
if got := fire(t, ps, "unlisted-src"); got != 1 {
|
||||
t.Errorf("default=%v: unpriced_reqs = %v, want 1", def, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
>>>>>>> 7941b41 (refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持)
|
||||
|
||||
@ -315,7 +315,7 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
||||
// headers were counted too and the check reported 17 vs 8 — a failure about
|
||||
// two unrelated tables. Stop at the rule editor's script.
|
||||
header := js[hStart:]
|
||||
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
|
||||
if cut := strings.Index(header, "/api/plugins/http/billing/rules"); cut > 0 {
|
||||
header = header[:cut]
|
||||
}
|
||||
// Count <th> with OR without attributes: the name column now carries
|
||||
|
||||
@ -72,6 +72,397 @@ local DEFAULT_PRICES = {
|
||||
}
|
||||
plugin.prices = DEFAULT_PRICES
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
-- ===== 规则 → 价格表编译(全部在插件内) ==============================
|
||||
--
|
||||
-- billing 没有任何内核逻辑(issue #1 的方向):规则文件、编译、计价、账目、
|
||||
-- 规则编辑页全部由本插件负责。内核既不知道 billing.json,也不知道
|
||||
-- free/token/subscription 这些语义 —— 它只做通用的"加载插件 / 持久化插件状态 /
|
||||
-- 提供通用 HTTP 与文件能力"。
|
||||
--
|
||||
-- 规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,
|
||||
-- 插件不该为了它去和内核对接;而编辑页跑在浏览器里,可以直接取 /api/sources
|
||||
-- 让运营选源名。`url: "*"` 只保留为"全局模型价目表"(模型维度本来就不分源)。
|
||||
local VALID_MODES = { free = true, token = true, subscription = true, unpriced = true }
|
||||
|
||||
local function trim(v)
|
||||
local s = tostring(v == nil and "" or v)
|
||||
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
-- USD per million → USD per token.
|
||||
local function usdPerM(v)
|
||||
local n = tonumber(trim(v))
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
local function longContextRate(lc, basePerToken)
|
||||
local prompt = trim(lc.prompt)
|
||||
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
|
||||
|
||||
-- 一条规则作用于哪个源。`source` 是正式写法;`url` 若不是 "*",仅在它恰好
|
||||
-- 等于源名时兼容(旧配置迁移用)。
|
||||
local function ruleSource(rule)
|
||||
local src = trim(rule.source)
|
||||
if src ~= "" then return src end
|
||||
local u = trim(rule.url)
|
||||
if u ~= "" and u ~= "*" then return u end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function applyRule(prices, rule, srcName)
|
||||
local mode = rule.mode
|
||||
if mode == "free" or mode == "subscription" then
|
||||
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
|
||||
if mode == "subscription" then
|
||||
if prices.subscriptions == nil then prices.subscriptions = {} end
|
||||
local cur = trim(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
|
||||
-- 刻意不写条目并显式记录:profile 的 default=free 也不能把它当"免费"。
|
||||
prices.explicit_unpriced[srcName] = true
|
||||
return true
|
||||
end
|
||||
if mode == "token" then
|
||||
if type(rule.models) ~= "table" or next(rule.models) == nil then
|
||||
return nil, string.format("token rule for %q requires at least one model",
|
||||
tostring(rule.source or 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
|
||||
if trim(t.reasoning) ~= "" then
|
||||
local rp, rerr = usdPerM(t.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 规则**不写源条目**:未列价的模型进入 unpriced 统计(可见)。
|
||||
return true
|
||||
end
|
||||
return nil, string.format("unknown mode %q", tostring(mode))
|
||||
end
|
||||
|
||||
-- compileRules(profile) → { prices, warnings } 或 nil, err。
|
||||
-- 不接收源列表:规则以源名为键,编译器不需要知道网关上有哪些源。
|
||||
local function compileRules(profile)
|
||||
if type(profile) ~= "table" then return nil, "no billing profile" end
|
||||
local prices = {
|
||||
currency = "USD",
|
||||
default = { prompt = 0, completion = 0, per_request = 0 },
|
||||
sources = {}, models = {}, keys = {}, explicit_unpriced = {},
|
||||
}
|
||||
if profile.default == "free" or profile.default == "unpriced" then
|
||||
prices.default_mode = profile.default
|
||||
end
|
||||
local rules = type(profile.rules) == "table" and profile.rules or {}
|
||||
for i = 1, #rules do
|
||||
local c = trim(rules[i].currency)
|
||||
if c ~= "" then prices.currency = c break end
|
||||
end
|
||||
local warnings = {}
|
||||
for i = 1, #rules do
|
||||
local rule = rules[i]
|
||||
local url = trim(rule.url)
|
||||
local src = ruleSource(rule)
|
||||
if url == "*" then
|
||||
local ok, err = applyRule(prices, rule, nil)
|
||||
if not ok then return nil, err end
|
||||
elseif src == nil then
|
||||
warnings[#warnings + 1] = "rule " .. i .. " 既没有 source 也没有 url=\"*\""
|
||||
else
|
||||
local ok, err = applyRule(prices, rule, src)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
end
|
||||
return { prices = prices, warnings = warnings }
|
||||
end
|
||||
|
||||
-- ===== 规则持久化:插件自己读写自己的配置文件 ========================
|
||||
--
|
||||
-- 规则文件放在**插件自己的目录**(内核通过通用的 __llmsproxy_plugin_dir 暴露,
|
||||
-- 任何插件都能用;内核不知道 billing 的存在):`<plugin_dir>/billing.json`。
|
||||
-- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动
|
||||
-- 价目表,反之亦然。
|
||||
plugin.rules = { active = "", profiles = {} }
|
||||
plugin.rules_path = ""
|
||||
|
||||
local function defaultRulesPath()
|
||||
local dir = rawget(_G, "__llmsproxy_plugin_dir")
|
||||
if type(dir) ~= "string" or dir == "" then return "" end
|
||||
return dir .. "/billing.json"
|
||||
end
|
||||
|
||||
local function readRulesFile(path)
|
||||
if path == "" then return nil end
|
||||
local f = io.open(path, "r")
|
||||
if f == nil then return nil end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if data == nil or data == "" then return nil end
|
||||
local ok, dec = pcall(json.decode, data)
|
||||
if not ok or type(dec) ~= "table" then
|
||||
return nil, "规则文件不是合法 JSON: " .. tostring(path)
|
||||
end
|
||||
return dec
|
||||
end
|
||||
|
||||
local function writeRulesFile(path, rules)
|
||||
if path == "" then return nil, "插件目录不可用,无法保存规则文件" end
|
||||
local ok, enc = pcall(json.encode, rules)
|
||||
if not ok then return nil, "规则无法序列化" end
|
||||
local tmp = path .. ".tmp"
|
||||
local f, err = io.open(tmp, "w")
|
||||
if f == nil then return nil, "无法写规则文件: " .. tostring(err) end
|
||||
local wok, werr = f:write(enc)
|
||||
f:close()
|
||||
if not wok then os.remove(tmp) return nil, "写规则文件失败: " .. tostring(werr) end
|
||||
local rok, rerr = os.rename(tmp, path)
|
||||
if not rok then
|
||||
os.remove(tmp)
|
||||
return nil, "替换规则文件失败: " .. tostring(rerr)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validateRulesShape(rules)
|
||||
if type(rules) ~= "table" then return nil, "规则必须是对象" end
|
||||
if rules.active ~= nil and type(rules.active) ~= "string" then return nil, "active 必须是字符串" end
|
||||
local profiles = rules.profiles
|
||||
if profiles == nil then profiles = {} rules.profiles = profiles end
|
||||
if type(profiles) ~= "table" then return nil, "profiles 必须是数组" end
|
||||
local seen = {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if type(p) ~= "table" then return nil, "profiles[" .. i .. "] 必须是对象" end
|
||||
local id = trim(p.id)
|
||||
if id == "" then return nil, "profiles[" .. i .. "] 缺少 id" end
|
||||
if seen[id] then return nil, "profile id 重复: " .. id end
|
||||
seen[id] = true
|
||||
if p.default ~= nil and p.default ~= "free" and p.default ~= "unpriced" then
|
||||
return nil, "profile " .. id .. " 的 default 只能是 free 或 unpriced"
|
||||
end
|
||||
if p.rules ~= nil and type(p.rules) ~= "table" then
|
||||
return nil, "profile " .. id .. " 的 rules 必须是数组"
|
||||
end
|
||||
for j, r in ipairs(p.rules or {}) do
|
||||
if type(r) ~= "table" then return nil, "profile " .. id .. " 的 rules[" .. j .. "] 必须是对象" end
|
||||
local mode = trim(r.mode)
|
||||
if not VALID_MODES[mode] then
|
||||
return nil, "profile " .. id .. " 的 rules[" .. j .. "] 的 mode 非法: " .. mode
|
||||
end
|
||||
local wildcard = trim(r.url) == "*"
|
||||
if ruleSource(r) == nil and not wildcard then
|
||||
return nil, "profile " .. id .. " 的 rules[" .. j .. "] 必须有 source(或 url=\"*\" 作全局模型价目)"
|
||||
end
|
||||
if mode == "token" and (type(r.models) ~= "table" or next(r.models) == nil) then
|
||||
return nil, "profile " .. id .. " 的 token 规则缺少 models"
|
||||
end
|
||||
end
|
||||
end
|
||||
if rules.active ~= nil and rules.active ~= "" and not seen[rules.active] then
|
||||
return nil, "active 指向不存在的 profile: " .. rules.active
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function activeProfile(rules)
|
||||
local profile = nil
|
||||
for _, p in ipairs(rules.profiles or {}) do
|
||||
if rules.active ~= nil and rules.active ~= "" and p.id == rules.active then
|
||||
profile = p
|
||||
break
|
||||
end
|
||||
end
|
||||
if profile == nil then profile = (rules.profiles or {})[1] end
|
||||
return profile
|
||||
end
|
||||
|
||||
-- applyRules:用给定规则重编译并注入 plugin.prices。启动、保存、切方案都走它。
|
||||
local function applyRules(rules)
|
||||
local res, err = compileRules(activeProfile(rules) or {})
|
||||
if not res then return nil, tostring(err) end
|
||||
plugin.prices = res.prices
|
||||
return res
|
||||
end
|
||||
|
||||
-- 启动即加载:插件被内核加载时就自己读规则文件并编译 —— 内核不需要调用任何
|
||||
-- billing 专用接口,它只知道"加载插件"。
|
||||
plugin.rules_path = defaultRulesPath()
|
||||
do
|
||||
local loaded, lerr = readRulesFile(plugin.rules_path)
|
||||
if lerr then
|
||||
plugin.load_error = lerr
|
||||
elseif loaded ~= nil then
|
||||
plugin.rules = loaded
|
||||
end
|
||||
local res, err = applyRules(plugin.rules)
|
||||
if not res then plugin.load_error = tostring(err) end
|
||||
end
|
||||
|
||||
-- ---- 规则编辑器的 HTTP 面(挂在插件自己的 /api/plugins/http/billing/ 下)----
|
||||
-- 规则属于插件,写盘也在插件内完成;内核没有 billing 端点。
|
||||
function plugin.__http_rules_get(req)
|
||||
local out = { billing = plugin.rules, warnings = {} }
|
||||
local profile = activeProfile(plugin.rules or {})
|
||||
if profile then out.active = profile.id end
|
||||
local res = profile and compileRules(profile)
|
||||
if res and res.warnings then out.warnings = res.warnings end
|
||||
if plugin.load_error then out.error = plugin.load_error end
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_put(req)
|
||||
local body = (req and (req.json or req))
|
||||
if type(body) ~= "table" then
|
||||
return { status = 400, json = { error = "请求体必须是 JSON 对象" } }
|
||||
end
|
||||
local rules = body.billing or body
|
||||
local ok, verr = validateRulesShape(rules)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(rules)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, rules)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = rules
|
||||
return { status = 200, json = { ok = true, billing = rules, warnings = res.warnings } }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_add(req)
|
||||
local body = (req and (req.json or req)) or {}
|
||||
local copy = json.decode(json.encode(plugin.rules or { active = "", profiles = {} }))
|
||||
local target = nil
|
||||
for _, p in ipairs(copy.profiles or {}) do
|
||||
if body.profile == nil or body.profile == "" or p.id == body.profile then target = p break end
|
||||
end
|
||||
if target == nil then
|
||||
return { status = 400, json = { error = "没有该方案: " .. tostring(body.profile) } }
|
||||
end
|
||||
local rule = body.rule
|
||||
if type(rule) ~= "table" then return { status = 400, json = { error = "缺少 rule" } } end
|
||||
local key = ruleSource(rule)
|
||||
if key == nil then return { status = 400, json = { error = "规则必须有 source" } } end
|
||||
target.rules = target.rules or {}
|
||||
local replaced = false
|
||||
for i, r in ipairs(target.rules) do
|
||||
if ruleSource(r) == key and trim(r.url) == trim(rule.url) then
|
||||
target.rules[i] = rule replaced = true break
|
||||
end
|
||||
end
|
||||
if not replaced then target.rules[#target.rules + 1] = rule end
|
||||
local ok, verr = validateRulesShape(copy)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(copy)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, copy)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = copy
|
||||
return { status = 200, json = { ok = true, billing = copy } }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_delete(req)
|
||||
local body = (req and (req.json or req)) or {}
|
||||
local copy = json.decode(json.encode(plugin.rules or { active = "", profiles = {} }))
|
||||
local target = nil
|
||||
for _, p in ipairs(copy.profiles or {}) do
|
||||
if body.profile == nil or body.profile == "" or p.id == body.profile then target = p break end
|
||||
end
|
||||
if target == nil then
|
||||
return { status = 400, json = { error = "没有该方案: " .. tostring(body.profile) } }
|
||||
end
|
||||
local out = {}
|
||||
local found = false
|
||||
for _, r in ipairs(target.rules or {}) do
|
||||
if ruleSource(r) == body.source or trim(r.url) == trim(body.url) then
|
||||
found = true
|
||||
else
|
||||
out[#out + 1] = r
|
||||
end
|
||||
end
|
||||
if not found then return { status = 404, json = { error = "没有该规则" } } end
|
||||
target.rules = out
|
||||
local ok, verr = validateRulesShape(copy)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(copy)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, copy)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = copy
|
||||
return { status = 200, json = { ok = true, billing = copy } }
|
||||
end
|
||||
|
||||
-- 直调导出(/api/plugins/billing/call/<fn>):返回纯 JSON(不带 HTTP 状态壳),
|
||||
-- 便于运维/测试与插件自己的 HTTP 路由共用同一套实现。
|
||||
local function unwrap(res)
|
||||
if type(res) == "table" and res.json ~= nil then return res.json end
|
||||
return res
|
||||
end
|
||||
function plugin.rules_get(args) return unwrap(plugin.__http_rules_get({ json = args or {} })) end
|
||||
function plugin.rules_put(args) return unwrap(plugin.__http_rules_put({ json = args or {} })) end
|
||||
function plugin.rules_add(args) return unwrap(plugin.__http_rules_add({ json = args or {} })) end
|
||||
function plugin.rules_delete(args) return unwrap(plugin.__http_rules_delete({ json = args or {} })) end
|
||||
|
||||
plugin.http = {
|
||||
{ path = "billing/rules", method = "GET", handler = "__http_rules_get",
|
||||
roles = { "admin" }, description = "计费规则读取" },
|
||||
{ path = "billing/rules", method = "PUT", handler = "__http_rules_put",
|
||||
roles = { "admin" }, description = "替换整份计费规则(插件自己写盘)" },
|
||||
{ path = "billing/rules", method = "POST", handler = "__http_rules_add",
|
||||
roles = { "admin" }, description = "新增/替换一条计费规则" },
|
||||
{ path = "billing/rules", method = "DELETE", handler = "__http_rules_delete",
|
||||
roles = { "admin" }, description = "删除一条计费规则" },
|
||||
}
|
||||
|
||||
-- 测试/运维直调导出(/api/plugins/billing/call/...);不是内核接口。
|
||||
plugin.exports = { "rules_get", "rules_put", "rules_add", "rules_delete" }
|
||||
|
||||
>>>>>>> 7941b41 (refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持)
|
||||
-- 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
|
||||
@ -236,6 +627,19 @@ local function priceFor(payload)
|
||||
if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end
|
||||
if kp.peak ~= nil then out.peak = kp.peak end
|
||||
end
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
-- profile 级 default 策略(编译期写入 prices.default_mode)在这里落地:
|
||||
-- `free` = 运营声明"没有单独规则的源一律免费" ⇒ 计为已定价(不进 unpriced 统计);
|
||||
-- 但显式 mode=unpriced 的源必须仍计入,否则"只统计不收费"的声明失效。
|
||||
if not out.priced and p.default_mode == "free" then
|
||||
local explicit = p.explicit_unpriced
|
||||
if not (type(explicit) == "table" and payload.source and explicit[payload.source]) then
|
||||
out.priced = true
|
||||
end
|
||||
end
|
||||
>>>>>>> 7941b41 (refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持)
|
||||
return out
|
||||
end
|
||||
|
||||
@ -1054,12 +1458,12 @@ plugin.ui = {
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var API = "/api/plugins/billing/rules";
|
||||
var API = "/api/plugins/http/billing/rules";
|
||||
var STR = {
|
||||
en: {
|
||||
title: "Billing rules", profile: "Profile", newProfile: "New profile",
|
||||
addRule: "Add rule", delRule: "Delete", exportYaml: "Export YAML",
|
||||
url: "Source URL", mode: "Mode", prompt: "Prompt $/M",
|
||||
source: "Source", mode: "Mode", prompt: "Prompt $/M",
|
||||
completion: "Completion $/M", currency: "Currency",
|
||||
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
||||
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
||||
@ -1071,11 +1475,11 @@ plugin.ui = {
|
||||
lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
|
||||
noProfile: "No profile yet — create one to start pricing.",
|
||||
noRules: "No rules in this profile",
|
||||
warnNoSource: "matches no configured source",
|
||||
warnNoSource: "no such source on this gateway",
|
||||
active: "ACTIVE", setActive: "Set active",
|
||||
saving: "saving…", saved: "Saved", rejected: "Rejected", save: "Save",
|
||||
newProfileId: "New profile id",
|
||||
needUrl: "a rule needs a URL", needPrices: "every model needs both prices",
|
||||
needUrl: "a rule needs a source name (or url '*' for a global model catalogue)", needPrices: "every model needs both prices",
|
||||
badPeakHours: "peak hours look like 1-4,6-10 (UTC, 0-23)",
|
||||
badPeakDays: "peak days are UTC weekday numbers 0-6",
|
||||
peakNeedsMult: "peak hours need a multiplier",
|
||||
@ -1085,7 +1489,7 @@ plugin.ui = {
|
||||
zh: {
|
||||
title: "计费规则", profile: "方案", newProfile: "新建方案",
|
||||
addRule: "新增规则", delRule: "删除", exportYaml: "导出 YAML",
|
||||
url: "源 URL", mode: "计费模式", prompt: "输入 $/M",
|
||||
source: "源(source 名)", mode: "计费模式", prompt: "输入 $/M",
|
||||
completion: "输出 $/M", currency: "币种",
|
||||
monthly: "月费", peakMult: "峰段倍数",
|
||||
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
||||
@ -1098,11 +1502,11 @@ plugin.ui = {
|
||||
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
||||
noProfile: "还没有方案 —— 新建一个开始定价。",
|
||||
noRules: "该方案暂无规则",
|
||||
warnNoSource: "匹配不到已配置的源",
|
||||
warnNoSource: "网关上没有这个源",
|
||||
active: "生效中", setActive: "设为生效",
|
||||
saving: "保存中…", saved: "已保存", rejected: "被拒绝", save: "保存",
|
||||
newProfileId: "新方案 id",
|
||||
needUrl: "规则必须有 URL",
|
||||
needUrl: "规则必须有源名(或 url '*' 作全局模型价目)",
|
||||
needPrices: "每个模型都要填输入和输出价格",
|
||||
badPeakHours: "峰段小时应形如 1-4,6-10(UTC,0-23)",
|
||||
badPeakDays: "峰段星期应为 UTC 星期编号 0-6",
|
||||
@ -1220,7 +1624,8 @@ plugin.ui = {
|
||||
}
|
||||
|
||||
function ruleRow(rule, known, T) {
|
||||
var unknown = rule.url !== "*" && known.indexOf(rule.url) < 0;
|
||||
var src = rule.source || (rule.url !== "*" ? rule.url : "");
|
||||
var unknown = src !== "" && src !== "*" && known.indexOf(src) < 0;
|
||||
var opts = [["free", T.mFree], ["token", T.mToken], ["subscription", T.mSub], ["unpriced", T.mUnpriced]]
|
||||
.map(function (o) {
|
||||
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
|
||||
@ -1230,8 +1635,8 @@ plugin.ui = {
|
||||
// 的固有宽度会把列撑开:实测表格 986px 挤在 954px 容器里,最右侧的
|
||||
// 「删除」列被切掉 32px,按钮点不到;r-cur/r-sub 这些只有 style='width:74px'
|
||||
// 的输入框实测只有 40px 可用,几乎不能输入。
|
||||
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
|
||||
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
|
||||
return "<tr class='br-rule' data-source='" + esc(src) + "'>" +
|
||||
"<td style='min-width:200px'><input class='r-src' value='" + esc(src) + "' list='br-srcs' placeholder='source 名,或 * = 全局模型价目' style='width:100%;box-sizing:border-box'>" +
|
||||
(unknown ? "<div style='color:#e0a33e;font-size:11px'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
||||
"</td>" +
|
||||
"<td style='width:88px'><select class='r-mode' style='width:100%;box-sizing:border-box;padding:2px;font-size:12px'>" + opts + "</select></td>" +
|
||||
@ -1248,10 +1653,11 @@ plugin.ui = {
|
||||
|
||||
// ---- collecting one rule back out of the DOM ----------------------------
|
||||
function collectRule(tr, T) {
|
||||
var url = tr.querySelector(".r-url").value.trim();
|
||||
if (!url) throw new Error(T.needUrl);
|
||||
var src = tr.querySelector(".r-src").value.trim();
|
||||
if (!src) throw new Error(T.needUrl);
|
||||
var mode = tr.querySelector(".r-mode").value;
|
||||
var out = { url: url, mode: mode };
|
||||
var out = { source: src, mode: mode };
|
||||
if (src === "*") { out.source = ""; out.url = "*"; }
|
||||
var cur = tr.querySelector(".r-cur").value.trim();
|
||||
if (cur) out.currency = cur;
|
||||
if (mode === "subscription") {
|
||||
@ -1401,7 +1807,8 @@ plugin.ui = {
|
||||
dsl.profiles.push({ id: id, rules: rules });
|
||||
});
|
||||
dsl.active = DATA && DATA.active ? DATA.active : (dsl.profiles[0] || {}).id;
|
||||
return "billing:\n" + yamlValue(dsl, 1) + "\n";
|
||||
// 规则现在是插件自持的 billing.json,导出就是同一个 JSON 结构。
|
||||
return JSON.stringify(dsl, null, 2) + "\n";
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
@ -1419,7 +1826,7 @@ plugin.ui = {
|
||||
var dsl = (DATA && DATA.billing) || null;
|
||||
var profiles = (dsl && dsl.profiles) || [];
|
||||
var active = (DATA && DATA.active) || (profiles[0] && profiles[0].id) || "";
|
||||
var known = (DATA && DATA.urls) || [];
|
||||
var known = (DATA && DATA.sources) || [];
|
||||
|
||||
var warn = "";
|
||||
if (DATA && DATA.warnings && DATA.warnings.length) {
|
||||
@ -1435,7 +1842,7 @@ plugin.ui = {
|
||||
return;
|
||||
}
|
||||
|
||||
var html = warn + "<datalist id='br-urls'>" + dl + "</datalist>";
|
||||
var html = warn + "<datalist id='br-srcs'>" + dl + "</datalist>";
|
||||
profiles.forEach(function (p) {
|
||||
var rules = p.rules || [];
|
||||
html += "<div data-profile='" + esc(p.id) + "' style='margin:0 0 18px'>" +
|
||||
@ -1533,7 +1940,7 @@ plugin.ui = {
|
||||
}
|
||||
if (idx < 0) return;
|
||||
dsl.profiles[idx].rules = dsl.profiles[idx].rules.filter(function (r) {
|
||||
return r.url !== tr.dataset.url;
|
||||
return (r.source || r.url) !== tr.dataset.source;
|
||||
});
|
||||
save(dsl);
|
||||
};
|
||||
@ -1547,10 +1954,10 @@ plugin.ui = {
|
||||
if (dsl.profiles[i].id === b.dataset.pid) idx = i;
|
||||
}
|
||||
if (idx < 0) return;
|
||||
// Seed with the first configured URL: an empty url row would fail
|
||||
// Seed with the first configured source: an empty source row would fail
|
||||
// validation on save and the operator would not know why.
|
||||
var first = ((DATA && DATA.urls) || [])[0] || "";
|
||||
dsl.profiles[idx].rules.push({ url: first, mode: "free" });
|
||||
var first = ((DATA && DATA.sources) || [])[0] || "*";
|
||||
dsl.profiles[idx].rules.push({ source: first === "*" ? "" : first, url: first === "*" ? "*" : nil, mode: "free" });
|
||||
save(dsl);
|
||||
};
|
||||
});
|
||||
@ -1627,19 +2034,27 @@ plugin.ui = {
|
||||
p.innerHTML = "<button class='ghost small' id='br-dl'>" + esc(T.exportYaml) + " (.yaml)</button>";
|
||||
holder.appendChild(p);
|
||||
document.getElementById("br-dl").onclick = function () {
|
||||
var blob = new Blob([text], { type: "text/yaml" });
|
||||
var blob = new Blob([text], { type: "application/json" });
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "billing-rules.yaml";
|
||||
a.download = "billing.json";
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
call("GET")
|
||||
.then(function (j) {
|
||||
DATA = { billing: j.billing || null, active: j.active, urls: j.urls || [], warnings: j.warnings || [] };
|
||||
Promise.all([
|
||||
call("GET"),
|
||||
// 源名列表来自网关自己的 /api/sources(浏览器带管理员会话),插件不需要
|
||||
// 为了它去和内核对接 —— 这正是"规则以源名为键"能做到零内核依赖的原因。
|
||||
fetch("/api/sources", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : { sources: [] }; })
|
||||
.catch(function () { return { sources: [] }; }),
|
||||
]).then(function (res) {
|
||||
var j = res[0], sj = res[1] || {};
|
||||
var names = (sj.sources || []).map(function (s) { return s.name; }).filter(Boolean);
|
||||
DATA = { billing: j.billing || null, active: j.active, sources: names, warnings: j.warnings || [] };
|
||||
render();
|
||||
})
|
||||
.catch(function (e) {
|
||||
|
||||
Reference in New Issue
Block a user