mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79bc55cfbe | |||
| 4b9fb7b056 | |||
| 7941b4110a |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.48",
|
||||
"version": "2.0.49",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.48",
|
||||
"version": "2.0.49",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.48",
|
||||
"version": "2.0.49",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -503,28 +503,29 @@ end
|
||||
|
||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||
|
||||
### 7.0 分层:计费语义在插件里,内核只给数据
|
||||
### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码
|
||||
|
||||
`config.yaml` 的 `billing:` 是**声明**,真正的语义(规则怎么变成价目表、
|
||||
覆盖顺序、未定价判定、峰谷/长上下文)全部在 `plugins/billing.lua`:
|
||||
`billing` 是**插件自己的事**:规则文件、编译、计价、账目、规则编辑页都由
|
||||
`plugins/billing.lua` 负责。内核里没有 billing 的配置结构、端点、编译或迁移逻辑
|
||||
—— 它只提供所有插件通用的能力(加载 `.lua`、插件状态持久化、插件目录、
|
||||
`plugin.http` 路由、插件可用的 `io`/`os`)。
|
||||
|
||||
| 层 | 职责 |
|
||||
| 项 | 位置 |
|
||||
|---|---|
|
||||
| 内核 | 解 YAML、校验 DSL schema(`BillingProfile`/`BillingRule`);把 **profile + 每个源的 `base_url`** 作为数据交给插件;把插件算出的价目注入 `prices` 侧车;把计费记录持久化到插件状态文件 |
|
||||
| 插件 | `compile_prices(profile, sources)`:URL 归一匹配、首条命中优先、`free`/`subscription`/`unpriced`/`token` 语义、USD/M → per-token、`peak` 窗口、`long_context` 边际价、`reasoning` 可选价、未匹配规则/源清单;`priceFor`/`costFor` 与账目累计 |
|
||||
| 规则文件 | `<plugin_dir>/billing.json`(插件自己读写;内核不知道这个文件) |
|
||||
| 计费账目 | 插件状态文件 `<plugin_dir>/.billing.state.json`(内核的通用状态持久化) |
|
||||
| 规则编辑 API | `/api/plugins/http/billing/rules`(GET/PUT/POST/DELETE,插件路由) |
|
||||
| 编译/计价 | 插件内 `compileRules()` / `priceFor()` / `costFor()` |
|
||||
|
||||
两条理由(GitCode issue #1):
|
||||
规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,插件
|
||||
不需要为了它和内核对接;编辑页在浏览器里,直接取 `/api/sources` 给运营选源名。
|
||||
`url: "*"` 只表示"全局模型价目表"(模型维度本来就不分源)。
|
||||
|
||||
1. 语义只写一处。曾经的 `compile.go`(Go)与 `priceFor`(Lua)各有一套覆盖
|
||||
语义,组合起来把模型价静默清零 —— 账单加总正常、只是少报。
|
||||
2. 插件沙箱**没有文件系统**,所以“插件直接读 config.yaml”在设计上不可行;
|
||||
内核把解码后的 profile 作为钩子/接口传入,就是这里的分层形态。
|
||||
> 历史:早期 `billing:` 写在 `config.yaml`、编译在 Go 的 `internal/billing` 里。
|
||||
> GitCode issue #1 的根因正是"Go 与 Lua 各写一半覆盖语义",组合起来把模型价
|
||||
> 静默清零。现在内核侧 billing 代码已全部删除,规则文件也由插件自持。
|
||||
|
||||
**计费记录持久化**:插件状态(含累计账目)写在 `plugin_dir/.billing.state.json`
|
||||
(同目录下 `.<插件名>.state.json`),随每次账目变更异步落盘,重启后恢复;
|
||||
`prices` 与 `state` 分开存,改价不会抹掉历史账目。
|
||||
|
||||
### 7.1 计费维度
|
||||
### 7.1 计费维度### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | token 价覆盖顺序 |
|
||||
|---|---|---|
|
||||
@ -540,8 +541,18 @@ end
|
||||
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
|
||||
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
|
||||
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
|
||||
`{prompt:0, completion:0}` 零价条目;`default: free` 给未匹配源写的是
|
||||
`{per_request:0}`(只标记、不碰 token 价)。
|
||||
`{prompt:0, completion:0}` 零价条目。
|
||||
|
||||
规则写法:
|
||||
|
||||
```json
|
||||
{"active":"current","profiles":[{"id":"current","default":"unpriced","rules":[
|
||||
{"source":"localzen","mode":"free"},
|
||||
{"source":"codearts","mode":"token",
|
||||
"models":{"glm-5.3":{"prompt":"0.15","completion":"0.60"}}},
|
||||
{"url":"*","mode":"token","models":{"some-model":{"prompt":"1.0","completion":"2.0"}}}
|
||||
]}]}
|
||||
```
|
||||
|
||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||
**`cache_discount`** 见 §7.7。
|
||||
@ -631,13 +642,15 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||
|
||||
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
|
||||
它是**编译期**策略(以前被校验后写进无人读取的 `default_mode` 键,两种取值运行时
|
||||
完全等价):
|
||||
它是插件运行时的策略(编译期写入 `prices.default_mode`):
|
||||
|
||||
| `default` | 编译结果 | `unpriced_reqs` |
|
||||
| `default` | 没有单独规则的源 | `unpriced_reqs` |
|
||||
|---|---|---|
|
||||
| `unpriced`(省略时) | 未匹配源不写条目 | +1 |
|
||||
| `free` | 未匹配源写 `{per_request:0}` 标记(只标记、不碰 token 价) | 不增加 |
|
||||
| `unpriced`(省略时) | 零价,计入未定价债务 | +1 |
|
||||
| `free` | 零价,视为**刻意免费** | 不增加 |
|
||||
|
||||
显式 `mode: unpriced` 的源即使在 `default: free` 下也仍然计入("只统计不收费"
|
||||
的声明不能被兜底策略吞掉)。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||
`total` 与各维度,只有金额是 0。
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -30,15 +29,6 @@ type Config struct {
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// BillingDSL declares per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Declarative, because hand-writing the prices JSON was error-prone in ways
|
||||
// that looked successful: the first PUT went out without the required
|
||||
// `prices` wrapper and silently REPLACED the accumulated totals with the
|
||||
// price table; the second used a peak-window shape the plugin does not read,
|
||||
// so peak traffic was billed at off-peak rates — no error, the bill just
|
||||
// quietly halved. A schema-checked config file catches both at load.
|
||||
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
|
||||
// PluginVisibility overrides the audience a plugin declared for itself,
|
||||
// keyed by plugin name: "admin" hides a plugin (and its state) from user
|
||||
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
|
||||
@ -487,182 +477,6 @@ type GWKey struct {
|
||||
// chain" so a user can clear an override back to the global default.
|
||||
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
|
||||
|
||||
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Profiles are the "let the user pick" axis: the same upstream URL can be
|
||||
// declared under multiple profiles, and the operator chooses which is active.
|
||||
// Switching recomputes and re-injects the billing plugin's prices table.
|
||||
type BillingDSL struct {
|
||||
// Active is the id of the profile in effect. Empty => the first profile.
|
||||
Active string `yaml:"active,omitempty" json:"active,omitempty"`
|
||||
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// BillingProfile is one named set of per-URL pricing rules.
|
||||
type BillingProfile struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Label string `yaml:"label,omitempty" json:"label,omitempty"`
|
||||
// Default applies when no rule matches: "free" or "unpriced".
|
||||
Default string `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// BillingRule declares the pricing for everything under one URL.
|
||||
type BillingRule struct {
|
||||
// URL matches a source's base_url. "*" = any unmatched URL.
|
||||
URL string `yaml:"url" json:"url"`
|
||||
// Mode is one of: free, token, subscription, unpriced.
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
// Subscription is the flat monthly cost (for mode=subscription).
|
||||
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
|
||||
// Models declares per-token prices when mode=token.
|
||||
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
// Free is true when mode=free (kept for explicitness).
|
||||
// Peak applies to ALL token-priced requests under this URL.
|
||||
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
|
||||
}
|
||||
|
||||
// BillingToken is the per-model price.
|
||||
type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
|
||||
// provider that charges them differently from ordinary output. Omit it and
|
||||
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
|
||||
// Anthropic all do, and what every source on this deployment reports.
|
||||
//
|
||||
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
|
||||
// billing plugin applies only the difference between this and Completion, so
|
||||
// setting it to a multiple of Completion reprices the thinking share rather
|
||||
// than billing it twice.
|
||||
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
// LongContext is the prompt-price step above a token threshold. Nil = one
|
||||
// flat rate, which is what a rule means when it says nothing.
|
||||
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPeak declares peak-hour pricing.
|
||||
type BillingPeak struct {
|
||||
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
|
||||
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
|
||||
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
|
||||
// Hours: each pair is [lo,hi] inclusive
|
||||
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// BillingLongContext is a prompt-price step for long inputs.
|
||||
//
|
||||
// Every major provider charges the input tokens ABOVE a threshold at a higher
|
||||
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
|
||||
// flat prompt price therefore under-bills exactly the traffic an agent workload
|
||||
// generates, and it under-bills it silently: the number is plausible, the bill
|
||||
// adds up, and nothing says the rate was wrong.
|
||||
//
|
||||
// The step applies to the PROMPT only. Output and cache reads are billed at
|
||||
// their own rates regardless of input length, which is how providers publish
|
||||
// it — the higher rate is for the extra context you made the model read, not for
|
||||
// what it wrote back.
|
||||
type BillingLongContext struct {
|
||||
// Above is the token count the step starts at. A request with exactly this
|
||||
// many prompt tokens is still at the base rate; the step covers the tokens
|
||||
// BEYOND it. Inclusive-below matches how the providers document it.
|
||||
Above int `yaml:"above" json:"above"`
|
||||
// Prompt is the USD-per-million rate for the tokens above Above.
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
// Multiplier is an alternative to Prompt: a factor applied to the base rate
|
||||
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
|
||||
// and setting neither is a validation error rather than a silent no-op.
|
||||
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects a BillingDSL that could not work as written.
|
||||
func (b *BillingDSL) Validate() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for i := range b.Profiles {
|
||||
p := &b.Profiles[i]
|
||||
if p.ID == "" {
|
||||
return fmt.Errorf("billing profile [%d] has no id", i)
|
||||
}
|
||||
if ids[p.ID] {
|
||||
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
|
||||
}
|
||||
ids[p.ID] = true
|
||||
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
|
||||
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
|
||||
}
|
||||
for j := range p.Rules {
|
||||
r := &p.Rules[j]
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
|
||||
}
|
||||
switch r.Mode {
|
||||
case "free", "token", "subscription", "unpriced":
|
||||
default:
|
||||
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
|
||||
}
|
||||
if r.Mode == "token" {
|
||||
for m, t := range r.Models {
|
||||
if t.Prompt == "" || t.Completion == "" {
|
||||
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
|
||||
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
|
||||
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Active must resolve to a profile id.
|
||||
if b.Active != "" && !ids[b.Active] {
|
||||
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the profile with the given id; empty id (or an id that does
|
||||
// not exist) falls back to the FIRST profile. A config with profiles but no
|
||||
// active marker should mean "use the one I wrote first" rather than "price
|
||||
// nothing", which is what a nil here would silently do.
|
||||
func (b *BillingDSL) Resolve(id string) *BillingProfile {
|
||||
if b == nil || len(b.Profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
if id != "" {
|
||||
for i := range b.Profiles {
|
||||
if b.Profiles[i].ID == id {
|
||||
return &b.Profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return &b.Profiles[0]
|
||||
}
|
||||
|
||||
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
|
||||
// per-single-token rate the plugin expects (price / 1e6).
|
||||
//
|
||||
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
|
||||
// through YAML editors, and a malformed value must fail VALIDATION (with the
|
||||
// file and field named) rather than parse as 0 and under-bill silently — which
|
||||
// is exactly the failure mode that motivated the DSL.
|
||||
func ParseFloatUSDPerM(s string) (float64, error) {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("not a number: %q", s)
|
||||
}
|
||||
if f < 0 {
|
||||
return 0, fmt.Errorf("negative price: %q", s)
|
||||
}
|
||||
return f / 1e6, nil
|
||||
}
|
||||
|
||||
// KeyQuota is retained only to carry a scope entry's caps through the admin
|
||||
// API. Quotas are per model, never per key: there is deliberately no key-wide
|
||||
// total, so exhausting one model's budget never blocks the others.
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// 一个过期的规则 URL 不得让**整个**计费 profile 失效。
|
||||
//
|
||||
// 真实事故(GitCode issue #1 排查中发现,生产现网):config.yaml 里
|
||||
// `billing.rules[0].url` 是 `https://opencode.ai/inference/v1`,而该源的
|
||||
// base_url 已是 `.../inference/openai/v1`。严格编译因此返回
|
||||
// "rule url ... matches no configured source base_url",applyBillingDSL 直接
|
||||
// return —— 于是**所有**计费规则都不生效:账单恒为 0、unpriced_reqs 涨到 3.7 万,
|
||||
// 而唯一的痕迹是一行启动日志。这正是「静默少报」最严重的一种。
|
||||
//
|
||||
// 修法:严格编译失败时降级为 lenient(用能匹配的规则),并打 ERROR 说明后果。
|
||||
// 一个 URL 写错不该把整个价目表一起葬送;但也不能装作成功。
|
||||
func TestBillingStaleRuleURLStillAppliesOtherRules(t *testing.T) {
|
||||
cfg := newTestConfig(t)
|
||||
dir := t.TempDir()
|
||||
cfg.PluginDir = filepath.Join(dir, "plugins")
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "localzen", BaseURL: "https://opencode.ai/inference/openai/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m"}}},
|
||||
}
|
||||
cfg.BillingDSL = &config.BillingDSL{
|
||||
Active: "p",
|
||||
Profiles: []config.BillingProfile{{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
// 过期 URL:没有任何源的 base_url 与它相等
|
||||
{URL: "https://opencode.ai/inference/v1", Mode: "free"},
|
||||
// 有效规则:必须仍然生效
|
||||
{URL: "https://opencode.ai/inference/openai/v1", Mode: "free"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
c := newTestCore(t, cfg)
|
||||
c.applyBillingDSL()
|
||||
|
||||
raw := c.plugins.Prices("billing")
|
||||
if raw == nil {
|
||||
t.Fatal("billing has no price table (profile never applied)")
|
||||
}
|
||||
prices, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("price table shape: %#v", raw)
|
||||
}
|
||||
sources, ok := prices["sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no sources: %#v", prices)
|
||||
}
|
||||
if _, ok := sources["localzen"]; !ok {
|
||||
t.Fatalf("一条过期规则 URL 把整个 profile 弄失效了:有效的 free 规则也没有生效,"+
|
||||
"结果是账单恒为 0 且没有告警。prices=%#v", prices)
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// compileBillingPrices turns a billing profile into the prices table the billing
|
||||
// plugin consumes — by ASKING THE PLUGIN TO DO IT.
|
||||
//
|
||||
// Why the kernel does not compile prices any more (GitCode issue #1, follow-up
|
||||
// direction): "how a rule becomes a price table" is billing semantics, and it
|
||||
// used to live in TWO places — `internal/billing/compile.go` produced source
|
||||
// entries with zero token prices, while `billing.lua`'s priceFor() applied the
|
||||
// source dimension after the model dimension. Each was defensible alone; together
|
||||
// they silently zeroed every request (cost 0, unpriced_reqs frozen). Splitting
|
||||
// the semantics across two languages is what made that possible.
|
||||
//
|
||||
// So the kernel keeps only what it must own — decoding config.yaml and validating
|
||||
// the DSL — and hands the PROFILE plus the source base URLs to the plugin as
|
||||
// plain DATA. The plugin compiles, and the kernel injects the result through the
|
||||
// existing `prices` sidecar (SetState), which also keeps price-vs-ledger
|
||||
// separation intact.
|
||||
//
|
||||
// A plugin sandbox has no filesystem access, so "the plugin reads the config
|
||||
// file" is not achievable by design; passing the decoded profile is the hook /
|
||||
// interface this architecture provides instead.
|
||||
func (c *Core) compileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
|
||||
if c.plugins == nil {
|
||||
return nil, nil, fmt.Errorf("plugins not loaded")
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, nil, fmt.Errorf("no billing profile")
|
||||
}
|
||||
// Structs go through JSON so the Lua side receives plain data (maps/slices):
|
||||
// pushGoValue is not a struct encoder, and a silent shape mismatch here would
|
||||
// look exactly like "the plugin priced nothing".
|
||||
rawProfile, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var prof map[string]interface{}
|
||||
if err := json.Unmarshal(rawProfile, &prof); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sources := make([]map[string]interface{}, 0, len(c.cfg.Sources))
|
||||
for _, s := range c.cfg.Sources {
|
||||
sources = append(sources, map[string]interface{}{"name": s.Name, "base_url": s.BaseURL})
|
||||
}
|
||||
out, err := c.plugins.Call("billing", "compile_prices", map[string]interface{}{
|
||||
"profile": prof, "sources": sources, "lenient": lenient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("billing plugin returned an unexpected shape: %T", out)
|
||||
}
|
||||
if msg, hasErr := m["error"]; hasErr {
|
||||
if e := fmt.Sprint(msg); e != "" && e != "<nil>" {
|
||||
return nil, nil, fmt.Errorf("%s", e)
|
||||
}
|
||||
}
|
||||
prices, ok := m["prices"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("billing plugin returned no prices table")
|
||||
}
|
||||
var unmatched []string
|
||||
if arr, ok := m["unmatched_rules"].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
unmatched = append(unmatched, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
return prices, unmatched, nil
|
||||
}
|
||||
|
||||
// CompileBillingPrices is compileBillingPrices for callers outside the package
|
||||
// (the rules editor validates an edit before persisting it).
|
||||
func (c *Core) CompileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
|
||||
return c.compileBillingPrices(profile, lenient)
|
||||
}
|
||||
@ -109,7 +109,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
|
||||
p["name"], p["version"], p["hooks"], p["ui"])
|
||||
}
|
||||
c.applyBillingDSL()
|
||||
c.store = config.NewStore(cfg.RuntimeFile)
|
||||
// Share one box between the runtime store and config.yaml so a single
|
||||
// master.key seals both files. config.Load left the config holding
|
||||
@ -1009,96 +1008,6 @@ func (c *Core) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// applyBillingDSL compiles the configured billing profile and injects it into
|
||||
// the billing plugin.
|
||||
//
|
||||
// Configured pricing replaces the hand-written JSON blob that used to be PUT
|
||||
// through the state API. That path was used twice in production and both times
|
||||
// it "succeeded" while being wrong: once without the required `prices` wrapper
|
||||
// (silently wiping the accumulated totals), once with a peak-window shape the
|
||||
// plugin does not read (peak traffic billed at off-peak rates, no error). A
|
||||
// typed config with load-time validation turns both mistakes into startup
|
||||
// errors naming the file and field.
|
||||
//
|
||||
// A DSL with no profile, or one that fails to compile, leaves the plugin's
|
||||
// prices untouched: billing keeps running on whatever it had, and the problem
|
||||
// is logged rather than becoming a startup failure — the gateway must forward
|
||||
// even when its cost accounting is misconfigured.
|
||||
func (c *Core) applyBillingDSL() {
|
||||
ps := c.plugins
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
// The billing plugin may simply not be installed; a DSL without it is a
|
||||
// no-op, not an error.
|
||||
var have bool
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] == "billing" && row["loaded"] == true {
|
||||
have = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !have {
|
||||
return
|
||||
}
|
||||
dsl := c.cfg.BillingDSL
|
||||
if dsl == nil || len(dsl.Profiles) == 0 {
|
||||
return
|
||||
}
|
||||
if err := dsl.Validate(); err != nil {
|
||||
log.Printf("[billing] config invalid, profile not applied: %v", err)
|
||||
return
|
||||
}
|
||||
profile := dsl.Resolve(dsl.Active)
|
||||
if profile == nil {
|
||||
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
||||
return
|
||||
}
|
||||
prices, staleRules, err := c.compileBillingPrices(profile, false)
|
||||
if err != nil {
|
||||
// ONE stale rule URL must not take the whole price table down with it.
|
||||
//
|
||||
// The strict compiler rejects a profile whose rule matches no source,
|
||||
// which is right for catching typos — but the runtime consequence used
|
||||
// to be a bare `return`: every rule stopped applying, the bill came out
|
||||
// at 0 and unpriced_reqs grew forever, with a single startup log line as
|
||||
// the only trace. Found in production while fixing GitCode issue #1
|
||||
// (a rule still pointed at .../inference/v1 after the source moved to
|
||||
// .../inference/openai/v1; 37k requests had been recorded as unpriced).
|
||||
//
|
||||
// So: apply the rules that DO match, and say exactly what was dropped.
|
||||
// The profile still fails loudly — it just no longer fails silently for
|
||||
// everything else.
|
||||
lenient, stale, lerr := c.compileBillingPrices(profile, true)
|
||||
if lerr != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
|
||||
profile.ID, err, lerr)
|
||||
return
|
||||
}
|
||||
def := profile.Default
|
||||
if def == "" {
|
||||
def = "unpriced"
|
||||
}
|
||||
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
|
||||
"source: %v — applied the remaining %d rule(s); unmatched traffic falls through "+
|
||||
"to default=%q and keeps counting in unpriced_reqs. FIX THE RULE URL: a stale URL "+
|
||||
"used to zero out the entire price table.", profile.ID, len(stale), stale,
|
||||
len(profile.Rules)-len(stale), def)
|
||||
prices, staleRules = lenient, stale
|
||||
}
|
||||
_ = staleRules
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
||||
// Sources that no rule matched are encoded by the COMPILER (the plugin):
|
||||
// `default: free` writes a {per_request:0} marker, `unpriced` leaves them out
|
||||
// so the plugin's counters see them. The old `unmatched_sources` key was
|
||||
// written by the compiler and read by nobody — deleted rather than kept as
|
||||
// decoration (GitCode issue #1 / PR #2).
|
||||
}
|
||||
|
||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||
// A typo in a period must be rejected at write time rather than silently
|
||||
// becoming a never-resetting budget — the opposite of what was typed.
|
||||
|
||||
@ -1,289 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// Billing rules API.
|
||||
//
|
||||
// GET /api/plugins/billing/rules the active profile's rules + all profiles
|
||||
// PUT /api/plugins/billing/rules replace the whole DSL and re-inject prices
|
||||
// POST /api/plugins/billing/rules { profile, rule } append one rule
|
||||
// DELETE /api/plugins/billing/rules { profile, url } remove one rule
|
||||
//
|
||||
// Why a separate endpoint instead of the generic plugin state: prices are
|
||||
// plugin state, but they are also a durable, reviewable CONFIGURATION. Routing
|
||||
// them through /state would let an admin key PUT arbitrary plugin state and
|
||||
// silently reset the accumulated accounting. Here the server owns the shape:
|
||||
// it validates the DSL, writes it back to config.yaml, recompiles, and hands
|
||||
// the plugin its prices table. The operator edits rules; the plugin keeps
|
||||
// numbers. The two are never mixed in one payload.
|
||||
//
|
||||
// Editing means "what the operator typed is what config.yaml holds". A
|
||||
// billing mistake found next week must be traceable to a reviewable file, not
|
||||
// to a blob in the plugin's state sidecar.
|
||||
func (g *Gateway) handleBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
g.getBillingRules(w)
|
||||
case http.MethodPut:
|
||||
g.putBillingRules(w, r)
|
||||
case http.MethodPost:
|
||||
g.addBillingRule(w, r)
|
||||
case http.MethodDelete:
|
||||
g.delBillingRule(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/POST/DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// billingRulesPayload is the UI-facing view: the profiles as declared, plus
|
||||
// which one is active and the source URLs they can match — the editor needs
|
||||
// the URL list to offer suggestions, and an operator typing a URL that matches
|
||||
// no source is exactly the silent-no-pricing failure this feature exists to
|
||||
// remove.
|
||||
type billingRulesPayload struct {
|
||||
DSL *config.BillingDSL `json:"billing"`
|
||||
Active string `json:"active"`
|
||||
URLs []string `json:"urls"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
func (g *Gateway) billingRulesPayload() billingRulesPayload {
|
||||
out := billingRulesPayload{}
|
||||
cfg := g.core.Config()
|
||||
if cfg != nil && cfg.BillingDSL != nil {
|
||||
// Copy so the response cannot be mutated back through the pointer.
|
||||
cp := *cfg.BillingDSL
|
||||
out.DSL = &cp
|
||||
if p := cp.Resolve(""); p != nil {
|
||||
out.Active = p.ID
|
||||
}
|
||||
}
|
||||
for _, s := range g.sourceURLs() {
|
||||
out.URLs = append(out.URLs, s)
|
||||
}
|
||||
out.Warnings = g.billingRuleWarnings()
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gateway) sourceURLs() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" || seen[s.BaseURL] {
|
||||
continue
|
||||
}
|
||||
seen[s.BaseURL] = true
|
||||
out = append(out, s.BaseURL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// billingRuleWarnings reports rules that match no configured source. This is
|
||||
// the silent killer of hand-written price tables: the rule parses, validates,
|
||||
// and prices nothing at all, so every request on that URL lands in unpriced.
|
||||
// Saying so out loud is the difference between a five-second fix and an
|
||||
// afternoon wondering why the bill is zero.
|
||||
func (g *Gateway) billingRuleWarnings() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
return nil
|
||||
}
|
||||
urls := map[string]bool{}
|
||||
for _, u := range g.sourceURLs() {
|
||||
urls[u] = true
|
||||
}
|
||||
var warns []string
|
||||
for _, p := range cfg.BillingDSL.Profiles {
|
||||
for _, r := range p.Rules {
|
||||
if r.URL == "*" || urls[r.URL] {
|
||||
continue
|
||||
}
|
||||
warns = append(warns, "profile "+p.ID+": rule url "+r.URL+" matches no configured source")
|
||||
}
|
||||
}
|
||||
return warns
|
||||
}
|
||||
|
||||
func (g *Gateway) getBillingRules(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusOK, g.billingRulesPayload())
|
||||
}
|
||||
|
||||
func (g *Gateway) putBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Billing *config.BillingDSL `json:"billing"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Billing == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "billing is required")
|
||||
return
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, body.Billing)
|
||||
}
|
||||
|
||||
func (g *Gateway) addBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
Rule config.BillingRule `json:"rule"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil || len(cfg.BillingDSL.Profiles) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured; create one first")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
// Replace rather than append when the URL is already declared: two rules
|
||||
// for one URL would silently make the first unreachable (first match wins),
|
||||
// which is the exact ambiguity an editor should not be able to create.
|
||||
replaced := false
|
||||
for i := range p.Rules {
|
||||
if p.Rules[i].URL == body.Rule.URL {
|
||||
p.Rules[i] = body.Rule
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
p.Rules = append(p.Rules, body.Rule)
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
func (g *Gateway) delBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
out := p.Rules[:0]
|
||||
found := false
|
||||
for _, rule := range p.Rules {
|
||||
if rule.URL == body.URL {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no rule for url "+body.URL)
|
||||
return
|
||||
}
|
||||
p.Rules = out
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
// applyBillingDSLUpdate is the single write path: validate, persist to
|
||||
// config.yaml, recompile, inject, and only then report success. If the config
|
||||
// cannot be written the change is NOT applied in memory either — a rules editor
|
||||
// that says "saved" and loses the edit on the next restart is worse than one
|
||||
// that refuses.
|
||||
func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.BillingDSL) {
|
||||
if err := next.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_rules", err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
writeError(w, http.StatusInternalServerError, "no_config", "no config loaded")
|
||||
return
|
||||
}
|
||||
// Assign BEFORE saving. The previous order saved first and assigned after,
|
||||
// so every rule edit reported success while writing a config.yaml with no
|
||||
// billing section at all — the operator's edit vanished on restart and
|
||||
// nothing in the API response said so.
|
||||
prev := cfg.BillingDSL
|
||||
cfg.BillingDSL = next
|
||||
|
||||
// Compile before persisting: a profile that cannot be turned into prices
|
||||
// must not reach the config file, or the next restart fails to load the
|
||||
// very rules the editor just accepted.
|
||||
//
|
||||
// The compilation itself runs in the BILLING PLUGIN (see
|
||||
// core.compileBillingPrices): the kernel only decodes config.yaml and passes
|
||||
// the profile + source URLs as data, so the "what does this rule mean"
|
||||
// semantics live in exactly one place. Editor saves are lenient: a
|
||||
// half-finished rule (URL typed before its source exists) must be saveable
|
||||
// and reported, not rejected with an error the operator can only resolve by
|
||||
// guessing.
|
||||
prices, _, err := g.core.CompileBillingPrices(next.Resolve(next.Active), true)
|
||||
if err != nil {
|
||||
cfg.BillingDSL = prev // no half-applied state
|
||||
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
cfg.BillingDSL = prev
|
||||
writeError(w, http.StatusInternalServerError, "persist_failed", err.Error())
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps != nil {
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
p := g.billingRulesPayload()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "billing": p.DSL, "active": p.Active, "warnings": p.Warnings,
|
||||
})
|
||||
}
|
||||
|
||||
func cloneBillingDSL(d *config.BillingDSL) *config.BillingDSL {
|
||||
out := &config.BillingDSL{Active: d.Active}
|
||||
out.Profiles = make([]config.BillingProfile, len(d.Profiles))
|
||||
copy(out.Profiles, d.Profiles)
|
||||
return out
|
||||
}
|
||||
|
||||
func profileIndex(d *config.BillingDSL, id string) int {
|
||||
if id == "" {
|
||||
return -1
|
||||
}
|
||||
for i, p := range d.Profiles {
|
||||
if p.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@ -1,308 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// rulesGateway needs a real billing plugin (the API injects prices into it)
|
||||
// AND two sources whose base_urls the rules will match, so it cannot reuse
|
||||
// either existing helper: gatewayWithBilling has no sources, and
|
||||
// newTestGateway loads no plugins.
|
||||
func rulesGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
g := gatewayWithBilling(t)
|
||||
cfg := g.core.Config()
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "localzen", BaseURL: "https://free.example.com/v1"},
|
||||
{Name: "commandcode", BaseURL: "https://api.commandcode.ai/v1"},
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func rulesDo(t *testing.T, g *Gateway, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return doReq(t, g, method, "/api/plugins/billing/rules", body)
|
||||
}
|
||||
|
||||
// TestBillingRulesCRUD covers the whole editor loop against the real HTTP
|
||||
// surface: read, add, replace-by-URL, delete. The replace-by-URL case is the
|
||||
// one worth pinning — two rules for one URL would make the first unreachable
|
||||
// (first match wins) without any error, which is exactly the kind of silent
|
||||
// ambiguity an editor must not be able to create.
|
||||
func TestBillingRulesCRUD(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p1","profiles":[{"id":"p1","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed rules = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Add a token-priced rule for the second URL.
|
||||
add := `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}}`
|
||||
if rr := rulesDo(t, g, http.MethodPost, add); rr.Code != http.StatusOK {
|
||||
t.Fatalf("add rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 2 {
|
||||
t.Fatalf("after add there are %d rules, want 2", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
|
||||
// Adding the SAME url again must replace, not append.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"free"}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("re-add same url = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
|
||||
t.Fatalf("re-adding an existing url appended instead of replacing: %d rules", n)
|
||||
}
|
||||
var mode string
|
||||
for _, r := range got.DSL.Profiles[0].Rules {
|
||||
if r.URL == "https://api.commandcode.ai/v1" {
|
||||
mode = r.Mode
|
||||
}
|
||||
}
|
||||
if mode != "free" {
|
||||
t.Errorf("re-added rule mode = %q, want free (the replacement must win)", mode)
|
||||
}
|
||||
|
||||
// Delete by URL.
|
||||
if rr := rulesDo(t, g, http.MethodDelete, `{"profile":"p1","url":"https://api.commandcode.ai/v1"}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("delete rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("after delete there are %d rules, want 1", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesRejectUnknownURLWithWarning is the silent-failure guard: a
|
||||
// rule whose URL matches no source prices nothing, so every request on it
|
||||
// falls into unpriced. The API must say so instead of accepting it silently.
|
||||
func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
if rr := rulesDo(t, g, http.MethodPut, `{"billing":{"active":"p","profiles":[{"id":"p",
|
||||
"rules":[{"url":"https://typo.example.com/v1","mode":"free"}]}]}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Warnings) == 0 {
|
||||
t.Fatal("a rule matching no configured source was accepted with no warning — " +
|
||||
"it will price nothing and every request will be recorded as unpriced")
|
||||
}
|
||||
if !strings.Contains(got.Warnings[0], "typo.example.com") {
|
||||
t.Errorf("warning does not name the offending url: %v", got.Warnings)
|
||||
}
|
||||
// The editor needs the real URL list to offer suggestions.
|
||||
if len(got.URLs) != 2 {
|
||||
t.Errorf("urls offered to the editor = %v, want the 2 configured base_urls", got.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesInvalidIsRejectedNotPersisted checks the write path refuses
|
||||
// bad input and leaves the previous rules in place — an editor that clears the
|
||||
// table on a typo loses the price table.
|
||||
func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// An unknown mode must be refused.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://x/v1","mode":"banana"}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid mode accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
// An unparseable price must be refused too, not silently priced at zero.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
|
||||
"models":{"m":{"prompt":"free","completion":"0.6"}}}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-numeric price accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesPersistAndReachThePlugin is the point of the whole
|
||||
// endpoint, and mutation verification showed the earlier CRUD tests missed it
|
||||
// twice: dropping cfg.Save() and dropping the SetState injection both left
|
||||
// every test green. Both failures are invisible in a GET — the response echoes
|
||||
// the in-memory rules either way — so this test checks the two consequences
|
||||
// that actually matter:
|
||||
//
|
||||
// 1. persistence: the rule must be in the config file on disk, because an
|
||||
// edit that evaporates on restart is a lie the UI told the operator;
|
||||
// 2. effect: the billing plugin must now PRICE the source (its published
|
||||
// prices table has a non-zero entry), because a rule that is stored but
|
||||
// never injected prices nothing at all.
|
||||
func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
cfg := g.core.Config()
|
||||
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"},
|
||||
{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// 1. persisted to the file the process loaded from.
|
||||
raw, err := os.ReadFile(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "commandcode.ai") {
|
||||
t.Errorf("the rule is not in config.yaml on disk — an edit that does not "+
|
||||
"survive a restart:\n%s", raw)
|
||||
}
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload saved config: %v", err)
|
||||
}
|
||||
if reloaded.BillingDSL == nil || len(reloaded.BillingDSL.Profiles) == 0 {
|
||||
t.Fatalf("saved config has no billing DSL: %s", raw)
|
||||
}
|
||||
// A round-trip through YAML must not lose the price strings.
|
||||
found := false
|
||||
for _, r := range reloaded.BillingDSL.Profiles[0].Rules {
|
||||
if strings.Contains(r.URL, "commandcode") {
|
||||
found = true
|
||||
if m, ok := r.Models["deepseek-v4.1-flash"]; !ok || m.Prompt != "0.15" {
|
||||
t.Errorf("price did not survive the YAML round-trip: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the token rule is missing after reload:\n%s", raw)
|
||||
}
|
||||
|
||||
// 2. reached the plugin. Prices live in a separate Lua field from `state`,
|
||||
// and the GET /state endpoint returns the published state together with
|
||||
// the prices sidecar — which is exactly what the plugin's own UI reads.
|
||||
state := g.core.Plugins().State("billing")
|
||||
if state == nil {
|
||||
t.Fatalf("billing plugin published no state")
|
||||
}
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Prices map[string]interface{} `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode state envelope: %v", err)
|
||||
}
|
||||
prices := envelope.Prices
|
||||
if prices == nil {
|
||||
t.Fatalf("state payload carries no prices")
|
||||
}
|
||||
sources, ok := prices["sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no sources map: %#v", prices)
|
||||
}
|
||||
// A token rule must NOT produce a source entry at all: priceFor() applies
|
||||
// the source dimension after the model dimension and lets it override the
|
||||
// token rate, so a marker here is what silently zeroed the bill (issue #1 /
|
||||
// PR #2). Models absent from the rule surface as unpriced instead.
|
||||
if entry, ok := sources["commandcode"]; ok {
|
||||
t.Errorf("the token rule produced a source entry %#v — it overrides the "+
|
||||
"model price (issue #1)", entry)
|
||||
}
|
||||
// The free rule must price localzen, and the two must not be conflated.
|
||||
if _, ok := sources["localzen"]; !ok {
|
||||
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
||||
}
|
||||
// Invariant: only DELIBERATE zero-token entries (mode=free/subscription) may
|
||||
// carry prompt/completion, and they must be exactly 0. In this fixture the
|
||||
// free rule covers localzen, so any other source with token prices — or a
|
||||
// non-zero one — would override model rates (issue #1).
|
||||
for name, raw := range sources {
|
||||
entry, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name == "localzen" { // the explicit free rule of this fixture
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"prompt", "completion"} {
|
||||
if _, present := entry[key]; present {
|
||||
t.Errorf("source %q carries %s=%v: it overrides every model price "+
|
||||
"on that source (issue #1)", name, key, entry[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
// The injected MODEL price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
|
||||
// Asserting the exact magnitude catches a unit error (per-million vs
|
||||
// per-token), which would be off by a factor of a million and still look
|
||||
// like "a number".
|
||||
//
|
||||
// ⚠️ This used to read `cc["models"]`, but `models` lives at
|
||||
// prices["models"], not inside a source entry — so the type assertion was
|
||||
// always false and the whole block was skipped. It had never run, which is
|
||||
// how a per-million unit error could survive a test whose comment claims
|
||||
// to catch exactly that.
|
||||
models, ok := prices["models"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no models map: %#v", prices)
|
||||
}
|
||||
m, ok := models["deepseek-v4.1-flash"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("the model price was not injected: %v", keysOf(models))
|
||||
}
|
||||
if p, _ := m["prompt"].(float64); p <= 0 || p > 1e-6 {
|
||||
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
||||
"a per-million value here would over-bill by 1e6x", p)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBillingRulesRequireAdmin keeps price rewriting behind the admin role,
|
||||
// like every other plugin write: prices are configuration, not user data.
|
||||
func TestBillingRulesRequireAdmin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
for _, m := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(m, "/api/plugins/billing/rules", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if rr := newRecorderFor(t, g, req); rr.Code != http.StatusForbidden {
|
||||
t.Errorf("user %s rules = %d, want 403", m, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -247,11 +247,6 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"):
|
||||
g.handlePluginUI(w, r)
|
||||
case r.URL.Path == "/api/plugins/billing/rules":
|
||||
// Checked BEFORE the generic /api/plugins/ prefix: the plugin router
|
||||
// treats anything after the name as a plugin sub-resource and would
|
||||
// 404 on "rules" instead of reaching the billing editor.
|
||||
g.handleBillingRules(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
|
||||
// Before the generic plugin router: these paths belong to a PLUGIN, not to
|
||||
// the plugin manager, and "http" is a valid sub-resource name otherwise.
|
||||
|
||||
15
internal/gateway/test_helpers_test.go
Normal file
15
internal/gateway/test_helpers_test.go
Normal file
@ -0,0 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import "sort"
|
||||
|
||||
// keysOf returns the sorted keys of a map, for readable failure messages.
|
||||
// (It used to live in the deleted billing-rules test file; other gateway tests
|
||||
// still use it.)
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@ -1,402 +0,0 @@
|
||||
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 是无人读取的死键,不应再写入")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
@ -1318,51 +1318,54 @@ func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
}
|
||||
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 {
|
||||
// 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)
|
||||
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 _, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules}); err != nil {
|
||||
t.Fatalf("rules_put: %v", err)
|
||||
}
|
||||
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": m["prices"]})
|
||||
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": "unlisted", "key": "***p", "ok": true,
|
||||
"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)
|
||||
}
|
||||
|
||||
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}}
|
||||
base := func(def interface{}, rules []interface{}) map[string]interface{} {
|
||||
p := map[string]interface{}{"id": "p", "rules": rules}
|
||||
if def != nil {
|
||||
profile["default"] = def
|
||||
p["default"] = def
|
||||
}
|
||||
if got := run(t, profile); got != 1 {
|
||||
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,53 +72,31 @@ 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 可选价、未匹配规则/源)全部在这里。
|
||||
-- billing 没有任何内核逻辑(issue #1 的方向):规则文件、编译、计价、账目、
|
||||
-- 规则编辑页全部由本插件负责。内核既不知道 billing.json,也不知道
|
||||
-- free/token/subscription 这些语义 —— 它只做通用的"加载插件 / 持久化插件状态 /
|
||||
-- 提供通用 HTTP 与文件能力"。
|
||||
--
|
||||
-- 这样内核不需要认识任何计费语义,也不会再出现"两处各写一半、组合起来
|
||||
-- 静默归零"——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
|
||||
-- 规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,
|
||||
-- 插件不该为了它去和内核对接;而编辑页跑在浏览器里,可以直接取 /api/sources
|
||||
-- 让运营选源名。`url: "*"` 只保留为"全局模型价目表"(模型维度本来就不分源)。
|
||||
local VALID_MODES = { free = true, token = true, subscription = true, unpriced = true }
|
||||
|
||||
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 function trim(v)
|
||||
local s = tostring(v == nil and "" or v)
|
||||
s = s:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local n = tonumber(s)
|
||||
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
|
||||
|
||||
-- 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
|
||||
@ -136,11 +114,8 @@ local function compilePeak(pk)
|
||||
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 prompt = trim(lc.prompt)
|
||||
local mult = tonumber(lc.multiplier) or 0
|
||||
if prompt ~= "" then
|
||||
local v, err = usdPerM(prompt)
|
||||
@ -151,29 +126,37 @@ local function longContextRate(lc, basePerToken)
|
||||
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
|
||||
-- 显式零价:这里 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)
|
||||
local cur = trim(rule.currency)
|
||||
if cur == "" then cur = "USD" end
|
||||
prices.subscriptions[srcName] = {
|
||||
monthly = tonumber(rule.subscription) or 0, currency = cur,
|
||||
}
|
||||
prices.subscriptions[srcName] = { monthly = tonumber(rule.subscription) or 0, currency = cur }
|
||||
end
|
||||
return true
|
||||
end
|
||||
if mode == "unpriced" then
|
||||
-- 刻意不写源条目:让 unpriced_reqs/unpriced_models 抓住它。
|
||||
-- 刻意不写条目并显式记录: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("rule for url %q: mode token requires at least one model",
|
||||
tostring(rule.url))
|
||||
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)
|
||||
@ -182,12 +165,8 @@ local function applyRule(prices, rule, srcName)
|
||||
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 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
|
||||
@ -197,102 +176,306 @@ local function applyRule(prices, rule, srcName)
|
||||
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
|
||||
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 —— 它们写的是有意为之的零价标记。
|
||||
-- token 规则**不写源条目**:未列价的模型进入 unpriced 统计(可见)。
|
||||
return true
|
||||
end
|
||||
return nil, string.format("unknown mode %q", tostring(mode))
|
||||
end
|
||||
|
||||
local function compilePrices(profile, sources, lenient)
|
||||
-- compileRules(profile) → { prices, warnings } 或 nil, err。
|
||||
-- 不接收源列表:规则以源名为键,编译器不需要知道网关上有哪些源。
|
||||
local function compileRules(profile)
|
||||
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 = {},
|
||||
sources = {}, models = {}, keys = {}, explicit_unpriced = {},
|
||||
}
|
||||
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
|
||||
if profile.default == "free" or profile.default == "unpriced" then
|
||||
prices.default_mode = profile.default
|
||||
end
|
||||
local matched = {}
|
||||
local unmatched_rules = {}
|
||||
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 = 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
|
||||
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
|
||||
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
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
local unmatched_sources = {}
|
||||
for _, src in ipairs(sources) do
|
||||
if not matched[src.name] then unmatched_sources[#unmatched_sources + 1] = src.name 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
|
||||
return { prices = prices, unmatched_rules = unmatched_rules, unmatched_sources = unmatched_sources }
|
||||
local res, err = applyRules(plugin.rules)
|
||||
if not res then plugin.load_error = tostring(err) end
|
||||
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
|
||||
-- 插件 HTTP 分发把**整个请求表**交给 handler(method/path/body/headers…),
|
||||
-- body 是原始字符串;而直调导出(/call/...)拿到的就是参数表本身。这里统一
|
||||
-- 解出 JSON 载荷,避免"把请求信封当成规则写进 billing.json"(真实踩过:
|
||||
-- PUT 之后 billing.json 变成了整个请求的转储,规则全丢、价格表清空)。
|
||||
local function reqData(req)
|
||||
if type(req) ~= "table" then return {} end
|
||||
if type(req.json) == "table" then return req.json end
|
||||
if type(req.body) == "string" and req.body ~= "" then
|
||||
local ok, dec = pcall(json.decode, req.body)
|
||||
if ok and type(dec) == "table" then return dec end
|
||||
end
|
||||
if req.method == nil and req.path == nil then return req end
|
||||
return {}
|
||||
end
|
||||
|
||||
plugin.exports = plugin.exports or {}
|
||||
plugin.exports[#plugin.exports + 1] = "compile_prices"
|
||||
-- ---- 规则编辑器的 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 = reqData(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 = reqData(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 = reqData(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
|
||||
|
||||
-- 每条路由的 path 必须唯一:内核的路由冲突仲裁按**路径**(不看 method)判重,
|
||||
-- 同一路径挂 4 个 method 会被当成 4 个插件抢同一个路径。所以按操作拆路径。
|
||||
plugin.http = {
|
||||
{ path = "billing/rules", method = "GET", handler = "__http_rules_get",
|
||||
roles = { "admin" }, description = "计费规则读取" },
|
||||
{ path = "billing/rules/replace", method = "PUT", handler = "__http_rules_put",
|
||||
roles = { "admin" }, description = "替换整份计费规则(插件自己写盘)" },
|
||||
{ path = "billing/rules/add", method = "POST", handler = "__http_rules_add",
|
||||
roles = { "admin" }, description = "新增/替换一条计费规则" },
|
||||
{ path = "billing/rules/remove", method = "DELETE", handler = "__http_rules_delete",
|
||||
roles = { "admin" }, description = "删除一条计费规则" },
|
||||
}
|
||||
|
||||
-- 测试/运维直调导出(/api/plugins/billing/call/...);不是内核接口。
|
||||
plugin.exports = { "rules_get", "rules_put", "rules_add", "rules_delete" }
|
||||
|
||||
-- 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
|
||||
@ -463,6 +646,15 @@ local function priceFor(payload)
|
||||
if kp.peak ~= nil then out.peak = kp.peak end
|
||||
end
|
||||
|
||||
-- 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
|
||||
return out
|
||||
end
|
||||
|
||||
@ -1281,12 +1473,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)",
|
||||
@ -1298,11 +1490,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",
|
||||
@ -1312,7 +1504,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=周一)",
|
||||
@ -1325,11 +1517,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",
|
||||
@ -1347,8 +1539,9 @@ plugin.ui = {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
var OP_PATH = { GET: "", PUT: "/replace", POST: "/add", DELETE: "/remove" };
|
||||
function call(method, body) {
|
||||
return fetch(API, {
|
||||
return fetch(API + (OP_PATH[method] || ""), {
|
||||
method: method,
|
||||
credentials: "same-origin",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
@ -1447,7 +1640,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>";
|
||||
@ -1457,8 +1651,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>" +
|
||||
@ -1475,10 +1669,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") {
|
||||
@ -1628,7 +1823,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 --------------------------------------------------------------
|
||||
@ -1646,7 +1842,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) {
|
||||
@ -1662,7 +1858,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'>" +
|
||||
@ -1760,7 +1956,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);
|
||||
};
|
||||
@ -1774,10 +1970,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);
|
||||
};
|
||||
});
|
||||
@ -1854,19 +2050,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