mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义
按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑 全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把 profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices` 侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。 ## 变更 - **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试); - 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出 `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL); - `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient, 半成品规则可保存、可报告); - `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、 free/subscription/unpriced/token、USD/M→per-token、peak 窗口、 long_context 边际价、reasoning 可选价、default 策略、未匹配清单。 错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。 ## 与 PR #2(valuelesser)合并后的语义 - **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见), 不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口; - `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记; `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键; - 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。 ## 判据(原 Go 单测平移到 Lua + PR #2 的用例) - `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、 任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、 reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、 lenient 未匹配清单、硬错误 5 例、default 两态; - `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例 (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e; - `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级; - `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
This commit is contained in:
@ -503,6 +503,27 @@ end
|
|||||||
|
|
||||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||||
|
|
||||||
|
### 7.0 分层:计费语义在插件里,内核只给数据
|
||||||
|
|
||||||
|
`config.yaml` 的 `billing:` 是**声明**,真正的语义(规则怎么变成价目表、
|
||||||
|
覆盖顺序、未定价判定、峰谷/长上下文)全部在 `plugins/billing.lua`:
|
||||||
|
|
||||||
|
| 层 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| 内核 | 解 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` 与账目累计 |
|
||||||
|
|
||||||
|
两条理由(GitCode issue #1):
|
||||||
|
|
||||||
|
1. 语义只写一处。曾经的 `compile.go`(Go)与 `priceFor`(Lua)各有一套覆盖
|
||||||
|
语义,组合起来把模型价静默清零 —— 账单加总正常、只是少报。
|
||||||
|
2. 插件沙箱**没有文件系统**,所以“插件直接读 config.yaml”在设计上不可行;
|
||||||
|
内核把解码后的 profile 作为钩子/接口传入,就是这里的分层形态。
|
||||||
|
|
||||||
|
**计费记录持久化**:插件状态(含累计账目)写在 `plugin_dir/.billing.state.json`
|
||||||
|
(同目录下 `.<插件名>.state.json`),随每次账目变更异步落盘,重启后恢复;
|
||||||
|
`prices` 与 `state` 分开存,改价不会抹掉历史账目。
|
||||||
|
|
||||||
### 7.1 计费维度
|
### 7.1 计费维度
|
||||||
|
|
||||||
| 维度 | 用途 | token 价覆盖顺序 |
|
| 维度 | 用途 | token 价覆盖顺序 |
|
||||||
@ -516,10 +537,11 @@ end
|
|||||||
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
|
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
|
||||||
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
|
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
|
||||||
|
|
||||||
⚠️ 正因为 `sources` 会覆盖 `models`,**"标记某源已定价"的条目只能用
|
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
|
||||||
`{ "per_request": 0 }`**,不能写 `{ "prompt": 0, "completion": 0 }` ——
|
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
|
||||||
后者会把该源所有模型的 token 价清成 0。编译器为 `mode: token` 的具体 URL
|
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
|
||||||
规则生成的就是前者(GitCode issue #1)。
|
`{prompt:0, completion:0}` 零价条目;`default: free` 给未匹配源写的是
|
||||||
|
`{per_request:0}`(只标记、不碰 token 价)。
|
||||||
|
|
||||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||||
**`cache_discount`** 见 §7.7。
|
**`cache_discount`** 见 §7.7。
|
||||||
@ -608,15 +630,14 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
|||||||
|
|
||||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||||
|
|
||||||
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认):
|
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
|
||||||
|
它是**编译期**策略(以前被校验后写进无人读取的 `default_mode` 键,两种取值运行时
|
||||||
|
完全等价):
|
||||||
|
|
||||||
| `default` | 未匹配任何规则的源 | `unpriced_reqs` |
|
| `default` | 编译结果 | `unpriced_reqs` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unpriced`(省略时) | 记 0 价,计入未定价债务 | +1 |
|
| `unpriced`(省略时) | 未匹配源不写条目 | +1 |
|
||||||
| `free` | 记 0 价,但视为**刻意免费** | 不增加 |
|
| `free` | 未匹配源写 `{per_request:0}` 标记(只标记、不碰 token 价) | 不增加 |
|
||||||
|
|
||||||
另有 `prices.unmatched_sources`:列出没有规则命中的源,网关在应用 profile 时
|
|
||||||
会以 `[billing] WARNING` 打到日志里 —— 看到它就去补规则(或确认 `default` 选对了)。
|
|
||||||
|
|
||||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||||
`total` 与各维度,只有金额是 0。
|
`total` 与各维度,只有金额是 0。
|
||||||
|
|||||||
@ -1,359 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"llmsproxy/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Compile turns one billing profile into the prices table the billing plugin
|
|
||||||
// expects, resolving URL rules against the gateway's actual sources.
|
|
||||||
//
|
|
||||||
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
|
|
||||||
// because that is what a provider's price list is keyed on, and because several
|
|
||||||
// sources can point at the same URL. The plugin, however, looks up by
|
|
||||||
// `payload.source` (a source NAME) and by model — it has no idea what URL a
|
|
||||||
// request went to. So the URL match happens here, at compile time, where the
|
|
||||||
// config's name->base_url mapping is known, and the result is expressed in the
|
|
||||||
// dimensions the plugin already supports.
|
|
||||||
//
|
|
||||||
// Profiles are the "let the user choose" axis: the same URL can appear in
|
|
||||||
// several profiles and switching recomputes this table, so a gateway can be
|
|
||||||
// repriced without editing the plugin.
|
|
||||||
func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) {
|
|
||||||
return CompileOpts(profile, sources, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompileOpts is Compile with an explicit policy for rules that match no
|
|
||||||
// source.
|
|
||||||
//
|
|
||||||
// The default is STRICT and that is correct for startup: a profile with a
|
|
||||||
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
|
|
||||||
// the bill silently comes out wrong. Failing the load is the right response.
|
|
||||||
//
|
|
||||||
// The rules editor needs the opposite. An operator editing a half-finished
|
|
||||||
// profile — adding a rule before the source that will use it exists, or
|
|
||||||
// renaming a URL while typing — must be able to save and then see the warning,
|
|
||||||
// not be blocked by an error they can only resolve by guessing. Lenient mode
|
|
||||||
// keeps the offending rule in the config and drops it from the compiled table,
|
|
||||||
// so it is preserved as text and clearly reported, without pretending it
|
|
||||||
// prices anything.
|
|
||||||
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) {
|
|
||||||
if profile == nil {
|
|
||||||
return nil, fmt.Errorf("no billing profile")
|
|
||||||
}
|
|
||||||
prices := map[string]interface{}{
|
|
||||||
"currency": "USD",
|
|
||||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
|
||||||
"sources": map[string]interface{}{},
|
|
||||||
"models": map[string]interface{}{},
|
|
||||||
"keys": map[string]interface{}{},
|
|
||||||
}
|
|
||||||
if profile.Default != "" {
|
|
||||||
prices["default_mode"] = profile.Default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Currency is a profile-level statement; a rule may override it.
|
|
||||||
for i := range profile.Rules {
|
|
||||||
if c := profile.Rules[i].Currency; c != "" {
|
|
||||||
prices["currency"] = c
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matched := map[string]bool{}
|
|
||||||
for i := range profile.Rules {
|
|
||||||
rule := &profile.Rules[i]
|
|
||||||
targets := matchSources(rule.URL, sources)
|
|
||||||
if len(targets) == 0 && rule.URL != "*" {
|
|
||||||
// A rule for a URL no source uses is almost always a typo (or a
|
|
||||||
// source that was removed). Failing loudly beats a profile that
|
|
||||||
// silently prices nothing — the whole reason this is a config file
|
|
||||||
// instead of a hand-written JSON blob.
|
|
||||||
if lenient {
|
|
||||||
continue // kept in the config text, reported as a warning
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL)
|
|
||||||
}
|
|
||||||
for _, srcName := range targets {
|
|
||||||
if matched[srcName] {
|
|
||||||
// First rule wins. Two rules matching one source is ambiguous,
|
|
||||||
// and silently letting the later one win makes the file's
|
|
||||||
// meaning depend on ordering the operator cannot see.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
matched[srcName] = true
|
|
||||||
if err := applyRule(prices, rule, srcName, sources); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sources no rule matched fall through to the profile default, which the
|
|
||||||
// plugin applies via prices.default. Recording them explicitly means the
|
|
||||||
// UI can say "this source is unpriced" instead of leaving the operator to
|
|
||||||
// infer it from a zero.
|
|
||||||
var unmatched []string
|
|
||||||
for _, src := range sources {
|
|
||||||
if !matched[src.Name] {
|
|
||||||
unmatched = append(unmatched, src.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(unmatched) > 0 {
|
|
||||||
prices["unmatched_sources"] = unmatched
|
|
||||||
}
|
|
||||||
return prices, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmatchedRuleURLs lists the non-wildcard rule URLs that match no configured
|
|
||||||
// source. Compile() treats such a URL as a hard error (a typo'd rule prices
|
|
||||||
// nothing), but the CALLER needs the list to say which rule to fix instead of
|
|
||||||
// just "compile failed" — and to explain what the degraded table dropped.
|
|
||||||
func UnmatchedRuleURLs(profile *config.BillingProfile, sources []config.Source) []string {
|
|
||||||
if profile == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for i := range profile.Rules {
|
|
||||||
rule := &profile.Rules[i]
|
|
||||||
if rule.URL == "*" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(matchSources(rule.URL, sources)) == 0 {
|
|
||||||
out = append(out, rule.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchSources returns the source names whose base_url matches pattern.
|
|
||||||
// "*" matches every source (used as a catch-all default rule).
|
|
||||||
func matchSources(pattern string, sources []config.Source) []string {
|
|
||||||
var out []string
|
|
||||||
if pattern == "*" {
|
|
||||||
for _, s := range sources {
|
|
||||||
out = append(out, s.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
want := normalizeURL(pattern)
|
|
||||||
for _, s := range sources {
|
|
||||||
if normalizeURL(s.BaseURL) == want {
|
|
||||||
out = append(out, s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeURL compares URLs the way an operator expects: trailing slashes and
|
|
||||||
// case in the host are differences the provider's price list does not care
|
|
||||||
// about, and requiring an exact byte match would make the config brittle.
|
|
||||||
func normalizeURL(u string) string {
|
|
||||||
u = strings.TrimSpace(u)
|
|
||||||
u = strings.TrimRight(u, "/")
|
|
||||||
return strings.ToLower(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyRule writes one rule's pricing for one source into the prices table.
|
|
||||||
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
|
|
||||||
switch rule.Mode {
|
|
||||||
case "free":
|
|
||||||
// Explicitly priced at zero. This is NOT the same as unpriced: a source
|
|
||||||
// the operator says is free must not appear in the unpriced warnings,
|
|
||||||
// or those warnings become noise and stop being read.
|
|
||||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
||||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "subscription":
|
|
||||||
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
|
|
||||||
// the flat fee is reported separately. Spreading a monthly fee across
|
|
||||||
// requests would invent a per-request number the provider never charges,
|
|
||||||
// and it would change every time traffic did.
|
|
||||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
||||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
|
||||||
}
|
|
||||||
fixed, _ := prices["subscriptions"].(map[string]interface{})
|
|
||||||
if fixed == nil {
|
|
||||||
fixed = map[string]interface{}{}
|
|
||||||
prices["subscriptions"] = fixed
|
|
||||||
}
|
|
||||||
cur := "USD"
|
|
||||||
if rule.Currency != "" {
|
|
||||||
cur = rule.Currency
|
|
||||||
}
|
|
||||||
fixed[srcName] = map[string]interface{}{
|
|
||||||
"monthly": rule.Subscription, "currency": cur,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "unpriced":
|
|
||||||
// Deliberately left out of `sources` so the plugin's unpriced_models /
|
|
||||||
// unpriced_reqs counters catch it. That is the point: a subscription
|
|
||||||
// plan whose credits cannot be converted to tokens must be VISIBLE as
|
|
||||||
// unbilled, not quietly estimated.
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "token":
|
|
||||||
if len(rule.Models) == 0 {
|
|
||||||
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
|
|
||||||
}
|
|
||||||
models := prices["models"].(map[string]interface{})
|
|
||||||
for modelID, t := range rule.Models {
|
|
||||||
entry := map[string]interface{}{}
|
|
||||||
prompt, err := usdPerM(t.Prompt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("model %q prompt: %w", modelID, err)
|
|
||||||
}
|
|
||||||
completion, err := usdPerM(t.Completion)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("model %q completion: %w", modelID, err)
|
|
||||||
}
|
|
||||||
entry["prompt"] = prompt
|
|
||||||
entry["completion"] = completion
|
|
||||||
if t.CacheDiscount != nil {
|
|
||||||
entry["cache_discount"] = *t.CacheDiscount
|
|
||||||
}
|
|
||||||
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
|
||||||
// rule does not set it, which is what makes the billing plugin skip
|
|
||||||
// the repricing branch entirely. Writing an explicit 0 here would
|
|
||||||
// mean "thinking is free" and silently under-bill every request
|
|
||||||
// from a provider that bills it like ordinary output.
|
|
||||||
if strings.TrimSpace(t.Reasoning) != "" {
|
|
||||||
rp, err := usdPerM(t.Reasoning)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
|
||||||
}
|
|
||||||
entry["reasoning_price"] = rp
|
|
||||||
}
|
|
||||||
if lc := t.LongContext; lc != nil {
|
|
||||||
if lc.Above <= 0 {
|
|
||||||
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
|
|
||||||
}
|
|
||||||
above, err := longContextRate(lc, prompt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("model %q long_context: %w", modelID, err)
|
|
||||||
}
|
|
||||||
entry["long_context"] = map[string]interface{}{
|
|
||||||
"above": lc.Above,
|
|
||||||
// Marginal, not blended. The plugin charges the tokens past the
|
|
||||||
// threshold at this rate and the rest at the base rate, so the
|
|
||||||
// threshold composes with a cache discount (which applies to the
|
|
||||||
// cached portion) instead of forcing the plugin to guess how
|
|
||||||
// the two interact.
|
|
||||||
"prompt": above,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if rule.Peak != nil {
|
|
||||||
entry["peak"] = compilePeak(rule.Peak)
|
|
||||||
}
|
|
||||||
models[modelID] = entry
|
|
||||||
}
|
|
||||||
// A specific-URL token rule needs a source entry so a model served
|
|
||||||
// from THIS url but absent from `rule.Models` is still recognised as
|
|
||||||
// belonging to a priced provider.
|
|
||||||
//
|
|
||||||
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
|
|
||||||
// says "these model ids have these prices, wherever they come from" —
|
|
||||||
// and marking every source priced at {0,0} would make priceFor() set
|
|
||||||
// priced=true for every model the catalogue does NOT list. Paid
|
|
||||||
// sources (commandcode, alittokenplan, …) would then record $0 instead
|
|
||||||
// of showing up in unpriced_reqs, which is precisely the silent
|
|
||||||
// zero-billing failure this whole plugin is built to make visible.
|
|
||||||
// The model lookup p.models[payload.model] already marks a listed
|
|
||||||
// model priced on its own, so the wildcard loses nothing.
|
|
||||||
if rule.URL != "*" {
|
|
||||||
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
|
||||||
// ONLY the marker — deliberately no "prompt"/"completion".
|
|
||||||
//
|
|
||||||
// priceFor() applies the source dimension AFTER the model
|
|
||||||
// dimension, and treats a present source key as an override
|
|
||||||
// (`sp.prompt ~= nil` => out.prompt = sp.prompt). Writing
|
|
||||||
// zeros here therefore rewrote the model price of every model
|
|
||||||
// this rule DOES list to 0 — while out.priced stayed true, so
|
|
||||||
// unpriced_reqs never grew either. Cost 0, no warning: GitCode
|
|
||||||
// issue #1.
|
|
||||||
//
|
|
||||||
// `per_request: 0` is the marker itself: it adds nothing to a
|
|
||||||
// request's cost, but its presence makes the plugin see the
|
|
||||||
// source as priced, which is the only thing this entry is for.
|
|
||||||
// (free/subscription above keep their zero token prices: there
|
|
||||||
// the zero IS the statement.)
|
|
||||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
||||||
"per_request": 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("unknown mode %q", rule.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// usdPerM converts a USD-per-million string into the per-token rate the plugin
|
|
||||||
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
|
|
||||||
// compiler agree byte-for-byte on what a valid price is — a value that passes
|
|
||||||
// Validate() but fails here (or vice versa) would be the worst kind of drift.
|
|
||||||
func usdPerM(s string) (float64, error) {
|
|
||||||
return config.ParseFloatUSDPerM(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compilePeak renders the peak window in the shape the plugin reads:
|
|
||||||
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
|
|
||||||
//
|
|
||||||
// The plugin reads `days` / `hours` pairs. The first version of the deployed
|
|
||||||
// price table used { start, end, weekdays } — a shape nothing reads — so peak
|
|
||||||
// traffic was billed at off-peak rates with no error anywhere. Compiling from
|
|
||||||
// typed config fields removes the chance of writing the wrong key names by hand.
|
|
||||||
func compilePeak(p *config.BillingPeak) map[string]interface{} {
|
|
||||||
win := map[string]interface{}{}
|
|
||||||
if len(p.Weekdays) > 0 {
|
|
||||||
days := make([]interface{}, 0, len(p.Weekdays))
|
|
||||||
for _, d := range p.Weekdays {
|
|
||||||
days = append(days, d)
|
|
||||||
}
|
|
||||||
win["days"] = days
|
|
||||||
}
|
|
||||||
if len(p.Hours) > 0 {
|
|
||||||
hours := make([]interface{}, 0, len(p.Hours))
|
|
||||||
for _, h := range p.Hours {
|
|
||||||
hours = append(hours, []interface{}{h[0], h[1]})
|
|
||||||
}
|
|
||||||
win["hours"] = hours
|
|
||||||
}
|
|
||||||
out := map[string]interface{}{"multiplier": p.Multiplier}
|
|
||||||
if len(win) > 0 {
|
|
||||||
out["windows"] = []interface{}{win}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
|
|
||||||
// threshold, given the model's base per-million prompt price.
|
|
||||||
//
|
|
||||||
// Two ways to express it are supported because providers publish both: an
|
|
||||||
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
|
|
||||||
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
|
|
||||||
// silent no-op, and an operator who wrote one meant something.
|
|
||||||
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
|
|
||||||
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
|
|
||||||
hasMult := lc.Multiplier > 0
|
|
||||||
switch {
|
|
||||||
case hasPrompt && hasMult:
|
|
||||||
p, err := usdPerM(lc.Prompt)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
// Both given: the absolute rate wins, because it is the one the provider
|
|
||||||
// actually quotes. Silently multiplying instead would produce a number
|
|
||||||
// the operator never wrote.
|
|
||||||
return p, nil
|
|
||||||
case hasPrompt:
|
|
||||||
return usdPerM(lc.Prompt)
|
|
||||||
case hasMult:
|
|
||||||
return basePerToken * lc.Multiplier, nil
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("needs either prompt or multiplier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"llmsproxy/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The wildcard token rule must not blanket-mark every source as priced.
|
|
||||||
//
|
|
||||||
// Why this is a hazard and not a style question: priceFor() sets priced=true as
|
|
||||||
// soon as the request's SOURCE has an entry in the price table, and that flag
|
|
||||||
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
|
|
||||||
// catalogue — it prices specific model ids wherever they come from — but
|
|
||||||
// compiling it into a {0,0,0} entry on all 21 sources made every model the
|
|
||||||
// catalogue does not list look "priced at zero". Paid providers (commandcode,
|
|
||||||
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
|
|
||||||
// exactly the silent zero-billing failure the plugin exists to surface.
|
|
||||||
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
|
|
||||||
sources := []config.Source{
|
|
||||||
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
|
|
||||||
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
|
|
||||||
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
|
|
||||||
}
|
|
||||||
profile := &config.BillingProfile{
|
|
||||||
ID: "p",
|
|
||||||
Rules: []config.BillingRule{
|
|
||||||
{URL: "https://free1.example.com/v1", Mode: "free"},
|
|
||||||
{
|
|
||||||
URL: "*", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
prices, err := Compile(profile, sources)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compile: %v", err)
|
|
||||||
}
|
|
||||||
srcs := prices["sources"].(map[string]interface{})
|
|
||||||
|
|
||||||
// The explicitly free source keeps its entry (free is a deliberate 0).
|
|
||||||
if _, ok := srcs["free1"]; !ok {
|
|
||||||
t.Error("the explicit free rule lost its source entry")
|
|
||||||
}
|
|
||||||
// Paid sources must NOT be marked, or their unlisted models bill $0.
|
|
||||||
for _, name := range []string{"paid1", "paid2"} {
|
|
||||||
if _, ok := srcs[name]; ok {
|
|
||||||
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
|
|
||||||
"models will bill $0 instead of appearing as unpriced", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The catalogue itself must still be present, or the rule prices nothing.
|
|
||||||
models := prices["models"].(map[string]interface{})
|
|
||||||
if _, ok := models["only-catalogued-model"]; !ok {
|
|
||||||
t.Error("the wildcard rule did not publish its model price")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
|
|
||||||
// because it genuinely declares pricing for the models served from it.
|
|
||||||
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
|
||||||
sources := []config.Source{
|
|
||||||
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
|
|
||||||
{Name: "other", BaseURL: "https://other.example.com/v1"},
|
|
||||||
}
|
|
||||||
profile := &config.BillingProfile{
|
|
||||||
ID: "p",
|
|
||||||
Rules: []config.BillingRule{{
|
|
||||||
URL: "https://mine.example.com/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
prices, err := Compile(profile, sources)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compile: %v", err)
|
|
||||||
}
|
|
||||||
srcs := prices["sources"].(map[string]interface{})
|
|
||||||
if _, ok := srcs["mine"]; !ok {
|
|
||||||
t.Error("a specific-url token rule must still mark its own source priced")
|
|
||||||
}
|
|
||||||
if _, ok := srcs["other"]; ok {
|
|
||||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
|
|
||||||
// thinking-token rate.
|
|
||||||
//
|
|
||||||
// A rule that says nothing about reasoning must produce a price entry with NO
|
|
||||||
// reasoning_price key, so the billing plugin skips the repricing branch and
|
|
||||||
// bills thinking at the ordinary output rate — which is what every provider
|
|
||||||
// actually charges. Writing an explicit 0 instead would read as "thinking is
|
|
||||||
// free" and silently under-bill every request from that model.
|
|
||||||
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
|
|
||||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
|
||||||
|
|
||||||
// Case 1: no reasoning declared → the key must not appear.
|
|
||||||
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
|
||||||
URL: "https://up.example/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
prices, err := Compile(p1, sources)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
|
||||||
if _, present := entry["reasoning_price"]; present {
|
|
||||||
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
|
|
||||||
entry["reasoning_price"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: declared → present, and converted to per-token like every other rate.
|
|
||||||
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
|
||||||
URL: "https://up.example/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
prices2, err := Compile(p2, sources)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
|
|
||||||
rp, ok := e2["reasoning_price"].(float64)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
|
|
||||||
}
|
|
||||||
if rp != 6e-6 {
|
|
||||||
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
|
|
||||||
}
|
|
||||||
// A malformed rate must be rejected, not silently ignored: an operator who
|
|
||||||
// typed "6 USD" expects either that value or an error, never a rule that
|
|
||||||
// looks applied and bills nothing.
|
|
||||||
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
|
||||||
URL: "https://up.example/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
if _, err := Compile(p3, sources); err == nil {
|
|
||||||
t.Error("a non-numeric reasoning rate must fail the compile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLongContextStepCompiles pins the long-context step's two accepted forms
|
|
||||||
// and its rejection of a step that would silently do nothing.
|
|
||||||
//
|
|
||||||
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
|
|
||||||
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
|
|
||||||
// missing: a step with no rate is a rule that LOOKS like it handles long
|
|
||||||
// context and does not, which under-bills without any visible sign.
|
|
||||||
func TestLongContextStepCompiles(t *testing.T) {
|
|
||||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
|
||||||
|
|
||||||
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
|
|
||||||
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
|
||||||
URL: "https://up.example/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
prices, err := Compile(p, sources)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
|
||||||
return e["long_context"].(map[string]interface{}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absolute rate (OpenAI's published shape).
|
|
||||||
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("absolute long_context rate rejected: %v", err)
|
|
||||||
}
|
|
||||||
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
|
|
||||||
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
|
|
||||||
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("multiplier long_context rejected: %v", err)
|
|
||||||
}
|
|
||||||
if lc2["prompt"] != 2e-6 {
|
|
||||||
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both given: the absolute rate wins, because that is the number the
|
|
||||||
// provider quotes. Multiplying instead would produce a figure the operator
|
|
||||||
// never wrote.
|
|
||||||
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if lc3["prompt"] != 5e-6 {
|
|
||||||
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neither: a no-op step must fail, not compile into a rule that silently
|
|
||||||
// bills long context at the base rate.
|
|
||||||
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
|
|
||||||
t.Error("a step with neither prompt nor multiplier must be rejected")
|
|
||||||
}
|
|
||||||
// A zero threshold would make every request "long".
|
|
||||||
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
|
|
||||||
t.Error("above=0 must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A specific-URL token rule marks its source priced, but the entry must carry
|
|
||||||
// ONLY the "this source is priced" marker — not zero token prices.
|
|
||||||
//
|
|
||||||
// Why: `billing.lua`'s priceFor() applies the source dimension AFTER the model
|
|
||||||
// dimension and treats `sp.prompt ~= nil` as an override. A {prompt:0,
|
|
||||||
// completion:0} entry therefore zeroes the model price of every model the rule
|
|
||||||
// DOES list. The symptom is silent: cost is 0 while out.priced is true, so
|
|
||||||
// unpriced_reqs does not grow either (GitCode issue #1).
|
|
||||||
//
|
|
||||||
// The original test only asserted the KEY existed, which froze the broken shape
|
|
||||||
// into a contract; this asserts the entry CONTENT.
|
|
||||||
func TestSpecificURLTokenRuleSourceEntryHasNoZeroTokenPrices(t *testing.T) {
|
|
||||||
sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}}
|
|
||||||
profile := &config.BillingProfile{
|
|
||||||
ID: "p",
|
|
||||||
Rules: []config.BillingRule{{
|
|
||||||
URL: "https://mine.example.com/v1", Mode: "token",
|
|
||||||
Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "0.15", Completion: "0.60"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
prices, err := Compile(profile, sources)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compile: %v", err)
|
|
||||||
}
|
|
||||||
entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("a specific-url token rule must still mark its own source priced")
|
|
||||||
}
|
|
||||||
for _, key := range []string{"prompt", "completion"} {
|
|
||||||
if _, present := entry[key]; present {
|
|
||||||
t.Errorf("source entry carries %s=%v: priceFor() treats it as an override "+
|
|
||||||
"and zeroes the model price (issue #1)", key, entry[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// per_request: 0 is the marker itself: adding a flat fee must not change
|
|
||||||
// the total, and the key's presence is what tells the plugin "priced".
|
|
||||||
if v, present := entry["per_request"]; !present || v != 0.0 {
|
|
||||||
t.Errorf("source entry must carry per_request:0 as the priced marker, got %#v", entry)
|
|
||||||
}
|
|
||||||
// The model price must still be the real per-token value.
|
|
||||||
m, ok := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("model price missing")
|
|
||||||
}
|
|
||||||
if m["prompt"] != 0.15e-6 {
|
|
||||||
t.Errorf("model prompt price = %v, want 1.5e-7 per token", m["prompt"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// free / subscription are DELIBERATE zeros: their source entries keep the
|
|
||||||
// zero token prices, because there the zero IS the statement.
|
|
||||||
func TestExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
|
|
||||||
for _, mode := range []string{"free", "subscription"} {
|
|
||||||
profile := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
|
||||||
URL: "https://mine.example.com/v1", Mode: mode, Subscription: 10,
|
|
||||||
}}}
|
|
||||||
prices, err := Compile(profile, []config.Source{
|
|
||||||
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s compile: %v", mode, err)
|
|
||||||
}
|
|
||||||
entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s: source entry missing", mode)
|
|
||||||
}
|
|
||||||
for _, key := range []string{"prompt", "completion"} {
|
|
||||||
if v, present := entry[key]; !present || v != 0.0 {
|
|
||||||
t.Errorf("%s: entry[%s] = %v (present=%v), want an explicit 0.0",
|
|
||||||
mode, key, v, present)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmatchedRuleURLs 是给调用方的"哪条规则写错了"清单:严格编译只会说
|
|
||||||
// "compile failed",而运维需要知道具体 URL;降级应用时也要能说明丢了什么。
|
|
||||||
func TestUnmatchedRuleURLs(t *testing.T) {
|
|
||||||
sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}}
|
|
||||||
profile := &config.BillingProfile{
|
|
||||||
ID: "p",
|
|
||||||
Rules: []config.BillingRule{
|
|
||||||
{URL: "https://mine.example.com/v1", Mode: "free"},
|
|
||||||
{URL: "https://stale.example.com/v1", Mode: "free"},
|
|
||||||
{URL: "*", Mode: "token", Models: map[string]config.BillingToken{
|
|
||||||
"m": {Prompt: "1", Completion: "1"},
|
|
||||||
}},
|
|
||||||
{URL: "https://mine.example.com/v1/", Mode: "free"}, // 尾斜杠归一后匹配
|
|
||||||
},
|
|
||||||
}
|
|
||||||
got := UnmatchedRuleURLs(profile, sources)
|
|
||||||
if len(got) != 1 || got[0] != "https://stale.example.com/v1" {
|
|
||||||
t.Fatalf("unmatched = %v, want only the stale URL (wildcard and trailing-slash "+
|
|
||||||
"variants must not be reported)", got)
|
|
||||||
}
|
|
||||||
if nil != UnmatchedRuleURLs(nil, sources) {
|
|
||||||
t.Error("nil profile must yield nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
84
internal/core/billing_compile.go
Normal file
84
internal/core/billing_compile.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@ -16,7 +16,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"llmsproxy/internal/billing"
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
"llmsproxy/internal/lua"
|
"llmsproxy/internal/lua"
|
||||||
"llmsproxy/internal/provider"
|
"llmsproxy/internal/provider"
|
||||||
@ -1055,7 +1054,7 @@ func (c *Core) applyBillingDSL() {
|
|||||||
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
prices, staleRules, err := c.compileBillingPrices(profile, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// ONE stale rule URL must not take the whole price table down with it.
|
// ONE stale rule URL must not take the whole price table down with it.
|
||||||
//
|
//
|
||||||
@ -1070,7 +1069,7 @@ func (c *Core) applyBillingDSL() {
|
|||||||
// So: apply the rules that DO match, and say exactly what was dropped.
|
// 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
|
// The profile still fails loudly — it just no longer fails silently for
|
||||||
// everything else.
|
// everything else.
|
||||||
lenient, lerr := billing.CompileOpts(profile, c.cfg.Sources, true)
|
lenient, stale, lerr := c.compileBillingPrices(profile, true)
|
||||||
if lerr != nil {
|
if lerr != nil {
|
||||||
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
|
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
|
||||||
profile.ID, err, lerr)
|
profile.ID, err, lerr)
|
||||||
@ -1083,34 +1082,21 @@ func (c *Core) applyBillingDSL() {
|
|||||||
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
|
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 "+
|
"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 "+
|
"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,
|
"used to zero out the entire price table.", profile.ID, len(stale), stale,
|
||||||
len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)),
|
len(profile.Rules)-len(stale), def)
|
||||||
billing.UnmatchedRuleURLs(profile, c.cfg.Sources),
|
prices, staleRules = lenient, stale
|
||||||
len(profile.Rules)-len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)), def)
|
|
||||||
prices = lenient
|
|
||||||
}
|
}
|
||||||
|
_ = staleRules
|
||||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
||||||
// unmatched_sources used to be written by the compiler and read by nobody,
|
// Sources that no rule matched are encoded by the COMPILER (the plugin):
|
||||||
// so a profile that silently left half the gateway unpriced looked applied
|
// `default: free` writes a {per_request:0} marker, `unpriced` leaves them out
|
||||||
// and healthy. Logging it is the cheap reader: an operator who sees source
|
// so the plugin's counters see them. The old `unmatched_sources` key was
|
||||||
// names here knows exactly which rules to add (GitCode issue #1 follow-up).
|
// written by the compiler and read by nobody — deleted rather than kept as
|
||||||
if unmatched, ok := prices["unmatched_sources"].([]string); ok && len(unmatched) > 0 {
|
// decoration (GitCode issue #1 / PR #2).
|
||||||
log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+
|
|
||||||
"profile default: %v (add rules, or set `default: free` if that is intended)",
|
|
||||||
len(unmatched), unmatched)
|
|
||||||
} else if raw, ok := prices["unmatched_sources"].([]interface{}); ok && len(raw) > 0 {
|
|
||||||
names := make([]string, 0, len(raw))
|
|
||||||
for _, v := range raw {
|
|
||||||
names = append(names, fmt.Sprint(v))
|
|
||||||
}
|
|
||||||
log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+
|
|
||||||
"profile default: %v (add rules, or set `default: free` if that is intended)",
|
|
||||||
len(names), names)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"llmsproxy/internal/billing"
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -238,7 +237,15 @@ func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.Bill
|
|||||||
// Compile before persisting: a profile that cannot be turned into prices
|
// 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
|
// must not reach the config file, or the next restart fails to load the
|
||||||
// very rules the editor just accepted.
|
// very rules the editor just accepted.
|
||||||
prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true)
|
//
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
cfg.BillingDSL = prev // no half-applied state
|
cfg.BillingDSL = prev // no half-applied state
|
||||||
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
||||||
|
|||||||
@ -224,27 +224,34 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("prices has no sources map: %#v", prices)
|
t.Fatalf("prices has no sources map: %#v", prices)
|
||||||
}
|
}
|
||||||
cc, ok := sources["commandcode"]
|
// A token rule must NOT produce a source entry at all: priceFor() applies
|
||||||
if !ok {
|
// the source dimension after the model dimension and lets it override the
|
||||||
t.Fatalf("the rule was saved but never injected: prices has %v, "+
|
// token rate, so a marker here is what silently zeroed the bill (issue #1 /
|
||||||
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
|
// PR #2). Models absent from the rule surface as unpriced instead.
|
||||||
}
|
if entry, ok := sources["commandcode"]; ok {
|
||||||
if cc == nil {
|
t.Errorf("the token rule produced a source entry %#v — it overrides the "+
|
||||||
t.Error("commandcode price entry is nil")
|
"model price (issue #1)", entry)
|
||||||
}
|
}
|
||||||
// The free rule must price localzen, and the two must not be conflated.
|
// The free rule must price localzen, and the two must not be conflated.
|
||||||
if _, ok := sources["localzen"]; !ok {
|
if _, ok := sources["localzen"]; !ok {
|
||||||
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
||||||
}
|
}
|
||||||
// The source entry exists ONLY as the "this source is priced" marker: it
|
// Invariant: only DELIBERATE zero-token entries (mode=free/subscription) may
|
||||||
// must not carry token prices, because priceFor() applies the source
|
// carry prompt/completion, and they must be exactly 0. In this fixture the
|
||||||
// dimension after the model dimension and would overwrite the model price
|
// free rule covers localzen, so any other source with token prices — or a
|
||||||
// with 0 (GitCode issue #1 — silent, unpriced_reqs does not grow either).
|
// non-zero one — would override model rates (issue #1).
|
||||||
if entry, ok := cc.(map[string]interface{}); ok {
|
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"} {
|
for _, key := range []string{"prompt", "completion"} {
|
||||||
if _, present := entry[key]; present {
|
if _, present := entry[key]; present {
|
||||||
t.Errorf("the injected source entry carries %s: it will zero the "+
|
t.Errorf("source %q carries %s=%v: it overrides every model price "+
|
||||||
"model price (issue #1): %#v", key, entry)
|
"on that source (issue #1)", name, key, entry[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
402
internal/lua/billing_compile_test.go
Normal file
402
internal/lua/billing_compile_test.go
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 规则 → 价格表的编译现在**在插件里**(GitCode issue #1 的建议方向:内核只提供
|
||||||
|
// 钩子/接口,计费语义下沉到 Lua)。这些判据从原 internal/billing 的 Go 单测
|
||||||
|
// 平移而来,并补上 issue #1 暴露的两个形状陷阱。
|
||||||
|
//
|
||||||
|
// 为什么必须保留这些判据:语义一旦分裂回两处,就会出现"源层零价覆盖模型价"
|
||||||
|
// 这类静默归零 —— 账单加总正常、只是少报,且 unpriced_reqs 也不涨。
|
||||||
|
func compilePricesForTest(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": sources, "lenient": lenient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile_prices: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("compile_prices returned %T", out)
|
||||||
|
}
|
||||||
|
if e, hasErr := m["error"]; hasErr && fmt.Sprint(e) != "" {
|
||||||
|
t.Fatalf("编译失败: %v", e)
|
||||||
|
}
|
||||||
|
if p, ok := m["prices"].(map[string]interface{}); ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
t.Fatalf("no prices in %#v", m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编译失败通过载荷里的 error 字段回传(内核桥的第二个返回值通道在插件导出上
|
||||||
|
// 不可靠;{error=...} 是本仓库的通行约定)。返回错误文本供断言。
|
||||||
|
func compilePricesErr(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) string {
|
||||||
|
t.Helper()
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": sources, "lenient": lenient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("compile_prices returned %T", out)
|
||||||
|
}
|
||||||
|
if e, hasErr := m["error"]; hasErr {
|
||||||
|
if s := fmt.Sprint(e); s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("预期编译失败,实得 %#v", m)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func srcList(base string) []map[string]interface{} {
|
||||||
|
return []map[string]interface{}{{"name": "mine", "base_url": base}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通配符 token 规则**不能**给源写零价条目:源层覆盖模型层,会把该源所有未列
|
||||||
|
// 价模型悄悄算成 0(原 Go 测试 TestWildcardTokenRuleDoesNotMarkPaidSources)。
|
||||||
|
func TestBillingCompileWildcardTokenDoesNotMarkSources(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "paid1", "base_url": "https://paid1.example.com/v1"},
|
||||||
|
{"name": "paid2", "base_url": "https://paid2.example.com/v1"},
|
||||||
|
{"name": "free1", "base_url": "https://free.example.com/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://free.example.com/v1", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
|
||||||
|
"only-catalogued-model": map[string]interface{}{"prompt": "1.0", "completion": "2.0"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
if _, ok := srcs["free1"]; !ok {
|
||||||
|
t.Error("显式 free 规则的源条目丢了(free 的 0 是语义)")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"paid1", "paid2"} {
|
||||||
|
if _, ok := srcs[name]; ok {
|
||||||
|
t.Errorf("通配符 token 规则把 %q 标成已定价/0 价 —— 其未列价模型会算成 0 而不是进入 unpriced", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models := prices["models"].(map[string]interface{})
|
||||||
|
if _, ok := models["only-catalogued-model"]; !ok {
|
||||||
|
t.Error("通配符规则没有发布模型价")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// token 规则**不得**产生源条目(PR #2 的结论,比 issue 正文的 per_request 标记更收口)。
|
||||||
|
//
|
||||||
|
// 只写 {per_request:0} 虽然不会清零模型价,但同源上未列入 rule.Models 的模型仍被
|
||||||
|
// 视为"已定价"、静默计 0、unpriced 计数不涨 —— 仍是静默少报。统一收口后它们
|
||||||
|
// 落到 unpriced 统计里(可见);真正"边际成本为零"用 free/subscription。
|
||||||
|
func TestBillingCompileTokenRuleEmitsNoSourceEntry(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
if entry, ok := srcs["mine"]; ok {
|
||||||
|
t.Errorf("token 规则产生了源条目 %#v:源层会覆盖模型价 / 静默计 0", entry)
|
||||||
|
}
|
||||||
|
// per-million → per-token:0.15 USD/M = 1.5e-7
|
||||||
|
m := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||||
|
if m["prompt"].(float64) != 1.5e-7 {
|
||||||
|
t.Errorf("prompt = %v, want 1.5e-7(per-token)", m["prompt"])
|
||||||
|
}
|
||||||
|
if m["completion"].(float64) != 6e-7 {
|
||||||
|
t.Errorf("completion = %v, want 6e-7", m["completion"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根因直防:token 规则的源条目不得携带 token 价。
|
||||||
|
// (free/subscription 是唯一合法的"有意零价",由下一个测试单独守住。)
|
||||||
|
func TestBillingCompileNoSourceEntryEverCarriesTokenPrices(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "a", "base_url": "https://a.example/v1"},
|
||||||
|
{"name": "b", "base_url": "https://b.example/v1"},
|
||||||
|
{"name": "c", "base_url": "https://c.example/v1"},
|
||||||
|
{"name": "d", "base_url": "https://d.example/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://a.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": "1", "completion": "2"}}},
|
||||||
|
map[string]interface{}{"url": "https://b.example/v1", "mode": "unpriced"},
|
||||||
|
map[string]interface{}{"url": "*", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m2": map[string]interface{}{"prompt": "3", "completion": "4"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
for name, raw := range prices["sources"].(map[string]interface{}) {
|
||||||
|
entry, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("源 %q 条目不是表: %#v", name, raw)
|
||||||
|
}
|
||||||
|
if _, bad := entry["prompt"]; bad {
|
||||||
|
t.Errorf("源 %q 携带 prompt=%v:会覆盖该源所有模型价", name, entry["prompt"])
|
||||||
|
}
|
||||||
|
if _, bad := entry["completion"]; bad {
|
||||||
|
t.Errorf("源 %q 携带 completion=%v:会覆盖该源所有模型价", name, entry["completion"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// free / subscription 的零价是**语义**,必须保留 prompt/completion=0。
|
||||||
|
func TestBillingCompileExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
|
||||||
|
for _, mode := range []string{"free", "subscription"} {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": mode, "subscription": 10,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||||
|
for _, key := range []string{"prompt", "completion"} {
|
||||||
|
if v, present := entry[key]; !present || v != 0.0 {
|
||||||
|
t.Errorf("%s: entry[%s] = %v (present=%v), want 显式 0", mode, key, v, present)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reasoning 未配置时必须**缺席**(不是 0)。写 0 等于"思考免费",会静默少报
|
||||||
|
// 所有把 reasoning 按输出计价的 provider。
|
||||||
|
func TestBillingCompileReasoningPriceOmittedUnlessSet(t *testing.T) {
|
||||||
|
base := map[string]interface{}{"prompt": "1.0", "completion": "2.0"}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"plain": base,
|
||||||
|
"reasoning": map[string]interface{}{"prompt": "1.0", "completion": "2.0", "reasoning": "3.0"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
models := prices["models"].(map[string]interface{})
|
||||||
|
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
|
||||||
|
t.Error("未配置 reasoning 的模型不应出现 reasoning_price(0 会被当成免费)")
|
||||||
|
}
|
||||||
|
rp := models["reasoning"].(map[string]interface{})["reasoning_price"].(float64)
|
||||||
|
if rp != 3e-6 {
|
||||||
|
t.Errorf("reasoning_price = %v, want 3e-6", rp)
|
||||||
|
}
|
||||||
|
_ = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// long_context:绝对值优先;只给倍数时按基础价的倍数;两者都缺要报错。
|
||||||
|
func TestBillingCompileLongContextRates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lc map[string]interface{}
|
||||||
|
want float64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"absolute", map[string]interface{}{"above": 128000, "prompt": "0.30"}, 3e-7, false},
|
||||||
|
{"multiplier", map[string]interface{}{"above": 128000, "multiplier": 2}, 3e-7, false},
|
||||||
|
{"both-absolute-wins", map[string]interface{}{"above": 128000, "prompt": "0.30", "multiplier": 5}, 3e-7, false},
|
||||||
|
{"neither", map[string]interface{}{"above": 128000}, 0, true},
|
||||||
|
{"above-zero", map[string]interface{}{"above": 0, "multiplier": 2}, 0, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.6",
|
||||||
|
"long_context": tc.lc},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if tc.wantErr {
|
||||||
|
compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
lc := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["long_context"].(map[string]interface{})
|
||||||
|
if lc["prompt"].(float64) != tc.want {
|
||||||
|
t.Errorf("%s: long_context.prompt = %v, want %v", tc.name, lc["prompt"], tc.want)
|
||||||
|
}
|
||||||
|
if lc["above"].(float64) != 128000 {
|
||||||
|
t.Errorf("%s: above = %v", tc.name, lc["above"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// peak 的形状是插件唯一读取的 {multiplier, windows=[{days,hours}]}。
|
||||||
|
func TestBillingCompilePeakShape(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}},
|
||||||
|
"peak": map[string]interface{}{"multiplier": 2,
|
||||||
|
"weekdays": []interface{}{1, 2}, "hours": []interface{}{[]interface{}{1, 4}}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
pk := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["peak"].(map[string]interface{})
|
||||||
|
if pk["multiplier"].(float64) != 2 {
|
||||||
|
t.Errorf("multiplier = %v", pk["multiplier"])
|
||||||
|
}
|
||||||
|
wins, ok := pk["windows"].([]interface{})
|
||||||
|
if !ok || len(wins) != 1 {
|
||||||
|
t.Fatalf("windows = %#v", pk["windows"])
|
||||||
|
}
|
||||||
|
win := wins[0].(map[string]interface{})
|
||||||
|
if len(win["days"].([]interface{})) != 2 || len(win["hours"].([]interface{})) != 1 {
|
||||||
|
t.Errorf("window = %#v", win)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 归一:尾斜杠与大小写不应影响匹配;首条命中优先。
|
||||||
|
func TestBillingCompileURLMatchingAndFirstWins(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "mine", "base_url": "https://Mine.Example.com/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1/", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||||
|
if entry["prompt"].(float64) != 0 {
|
||||||
|
t.Errorf("首条规则(free)应优先,实得 %#v", entry)
|
||||||
|
}
|
||||||
|
if _, ok := prices["models"].(map[string]interface{})["m"]; ok {
|
||||||
|
t.Error("第二条规则不应再被应用(首条命中优先,避免顺序歧义)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格模式:无源匹配的 URL 是硬错误;lenient 模式跳过并回报具体 URL。
|
||||||
|
func TestBillingCompileUnmatchedRules(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "https://stale.example.com/v1", "mode": "free"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if msg := compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false); msg == "" {
|
||||||
|
t.Fatal("严格模式必须报错")
|
||||||
|
}
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": srcList("https://mine.example.com/v1"), "lenient": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lenient: %v", err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
rules := m["unmatched_rules"].([]interface{})
|
||||||
|
if len(rules) != 1 || rules[0] != "https://stale.example.com/v1" {
|
||||||
|
t.Errorf("unmatched_rules = %#v", rules)
|
||||||
|
}
|
||||||
|
prices := m["prices"].(map[string]interface{})
|
||||||
|
if _, ok := prices["sources"].(map[string]interface{})["mine"]; !ok {
|
||||||
|
t.Error("lenient 模式下有效规则仍须生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬错误:未知 mode、token 无模型、负价、非数字、profile 不是表。
|
||||||
|
func TestBillingCompileHardErrors(t *testing.T) {
|
||||||
|
base := func(rule map[string]interface{}) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"id": "p", "rules": []interface{}{rule}}
|
||||||
|
}
|
||||||
|
url := "https://mine.example.com/v1"
|
||||||
|
src := srcList(url)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
profile map[string]interface{}
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{"unknown mode", base(map[string]interface{}{"url": url, "mode": "wat"}), "unknown mode"},
|
||||||
|
{"token no models", base(map[string]interface{}{"url": url, "mode": "token"}), "requires at least one model"},
|
||||||
|
{"negative price", base(map[string]interface{}{"url": url, "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "-1", "completion": "1"}}}), "negative price"},
|
||||||
|
{"not a number", base(map[string]interface{}{"url": url, "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "abc", "completion": "1"}}}), "not a number"},
|
||||||
|
{"profile not table", nil, "no billing profile"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
msg := compilePricesErr(t, tc.profile, src, false)
|
||||||
|
if !strings.Contains(msg, tc.wantSub) {
|
||||||
|
t.Errorf("%s: error = %q, want substring %q", tc.name, msg, tc.wantSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile.default 是"没有规则命中的源"的策略,以前被校验后写进无人读的
|
||||||
|
// default_mode 键,运行时行为与 unpriced 完全一样(PR #2 修复)。
|
||||||
|
func TestBillingCompileProfileDefault(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "listed", "base_url": "https://listed.example/v1"},
|
||||||
|
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
|
||||||
|
}
|
||||||
|
tokenRule := map[string]interface{}{
|
||||||
|
"url": "https://listed.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: free ⇒ 未匹配源写 {per_request:0}(只标记、不碰 token 价)
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p", "default": "free",
|
||||||
|
"rules": []interface{}{tokenRule},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
un, ok := srcs["unlisted"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("default: free 没有标记未匹配源: %#v", srcs)
|
||||||
|
}
|
||||||
|
if _, bad := un["prompt"]; bad {
|
||||||
|
t.Errorf("default-free 标记携带 prompt=%v:会清零该源模型价", un["prompt"])
|
||||||
|
}
|
||||||
|
if _, ok := srcs["listed"]; ok {
|
||||||
|
t.Error("default: free 不应标记已被规则命中的源")
|
||||||
|
}
|
||||||
|
if _, ok := prices["models"].(map[string]interface{})["m"]; !ok {
|
||||||
|
t.Error("token 规则的模型价丢了")
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: unpriced / 缺省 ⇒ 不写任何标记(让 unpriced 计数看得见)
|
||||||
|
for _, def := range []interface{}{"unpriced", nil} {
|
||||||
|
profile := map[string]interface{}{"id": "p", "rules": []interface{}{}}
|
||||||
|
if def != nil {
|
||||||
|
profile["default"] = def
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
if n := len(prices["sources"].(map[string]interface{})); n != 0 {
|
||||||
|
t.Errorf("default=%v 标记了 %d 个源;它们应保持未定价以便计数", def, n)
|
||||||
|
}
|
||||||
|
if _, present := prices["default_mode"]; present {
|
||||||
|
t.Error("default_mode 是无人读取的死键,不应再写入")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1318,40 +1318,51 @@ func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// profile 级 default(compile.go 写入的 default_mode)必须真的生效。
|
// profile 级 default 策略必须真的生效 —— 且它是**编译期**语义(PR #2):
|
||||||
//
|
// `default: free` 由 compile_prices 给未匹配的源写 {per_request:0} 标记;
|
||||||
// 真实缺陷(GitCode issue #1 顺带发现):compile.go:54 写 `default_mode`、
|
// `unpriced`/缺省不写。旧实现把 default 写进没人读的 default_mode 键,
|
||||||
// :104 写 `unmatched_sources`,但全仓库没有任何读取方 —— 于是 profile 的
|
// 两种取值运行时完全等价。
|
||||||
// `default: free|unpriced` 在运行时完全等价,区别只存在于 YAML 里。
|
func TestBillingProfileDefaultIsDecidedAtCompileTime(t *testing.T) {
|
||||||
// "free" 是**刻意定价为 0**,不该进未定价统计;"unpriced" 才该。
|
sources := []map[string]interface{}{
|
||||||
func TestBillingProfileDefaultMode(t *testing.T) {
|
{"name": "listed", "base_url": "https://listed.example/v1"},
|
||||||
cases := []struct {
|
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
|
||||||
mode string
|
|
||||||
wantUnpriced float64
|
|
||||||
}{
|
|
||||||
{"free", 0},
|
|
||||||
{"unpriced", 1},
|
|
||||||
{"", 1}, // 未声明 = 现有行为(未定价债务)
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
tokenRule := map[string]interface{}{
|
||||||
|
"url": "https://listed.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
|
||||||
|
}
|
||||||
|
run := func(t *testing.T, profile map[string]interface{}) float64 {
|
||||||
|
t.Helper()
|
||||||
ps, _ := billingVM(t)
|
ps, _ := billingVM(t)
|
||||||
prices := map[string]interface{}{
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
"profile": profile, "sources": sources, "lenient": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile_prices: %v", err)
|
||||||
}
|
}
|
||||||
if tc.mode != "" {
|
m := out.(map[string]interface{})
|
||||||
prices["default_mode"] = tc.mode
|
if e, bad := m["error"]; bad && fmt.Sprint(e) != "" {
|
||||||
|
t.Fatalf("compile: %v", e)
|
||||||
}
|
}
|
||||||
_ = ps.SetState("billing", map[string]interface{}{"prices": prices})
|
_ = ps.SetState("billing", map[string]interface{}{"prices": m["prices"]})
|
||||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||||
"model": "no-rule-model", "source": "no-rule-source", "key": "***p", "ok": true,
|
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
|
||||||
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
||||||
})
|
})
|
||||||
st := stateOf(t, ps)
|
return stateOf(t, ps)["unpriced_reqs"].(float64)
|
||||||
if got := st["unpriced_reqs"].(float64); got != tc.wantUnpriced {
|
}
|
||||||
t.Errorf("default_mode=%q: unpriced_reqs = %v, want %v", tc.mode, got, tc.wantUnpriced)
|
|
||||||
|
free := map[string]interface{}{"id": "p", "default": "free", "rules": []interface{}{tokenRule}}
|
||||||
|
if got := run(t, free); got != 0 {
|
||||||
|
t.Errorf("default=free: unpriced_reqs = %v, want 0(刻意免费,不该计入债务)", got)
|
||||||
|
}
|
||||||
|
for _, def := range []interface{}{"unpriced", nil} {
|
||||||
|
profile := map[string]interface{}{"id": "p", "rules": []interface{}{tokenRule}}
|
||||||
|
if def != nil {
|
||||||
|
profile["default"] = def
|
||||||
|
}
|
||||||
|
if got := run(t, profile); got != 1 {
|
||||||
|
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
|
||||||
}
|
}
|
||||||
// 无论哪种模式,成本都是 0;区别只在"是否算未定价债务"。
|
|
||||||
approx(t, "default_mode="+tc.mode+" cost",
|
|
||||||
st["total"].(map[string]interface{})["cost"].(float64), 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,228 @@ local DEFAULT_PRICES = {
|
|||||||
}
|
}
|
||||||
plugin.prices = DEFAULT_PRICES
|
plugin.prices = DEFAULT_PRICES
|
||||||
|
|
||||||
|
-- ===== 规则 → 价格表编译(Lua 侧,内核只给数据) ======================
|
||||||
|
--
|
||||||
|
-- 为什么编译在插件里(GitCode issue #1 的建议方向):
|
||||||
|
-- 内核只负责把 config.yaml 解成结构体(YAML 解析 + schema 校验),并把
|
||||||
|
-- profile 与源的 base_url 作为**数据**通过 plugin.compile_prices 传进插件;
|
||||||
|
-- "规则如何变成价目表"(free/subscription/unpriced/token、URL 归一匹配、
|
||||||
|
-- 首条命中优先、token 价的 per-million → per-token、peak 窗口、
|
||||||
|
-- long_context 边际价、reasoning 可选价、未匹配规则/源)全部在这里。
|
||||||
|
--
|
||||||
|
-- 这样内核不需要认识任何计费语义,也不会再出现"两处各写一半、组合起来
|
||||||
|
-- 静默归零"——issue #1 的真因正是 compile.go 与 priceFor() 各有一套覆盖语义。
|
||||||
|
--
|
||||||
|
-- 返回形态(供内核 SetState 注入 plugin.prices):
|
||||||
|
-- { prices = <价目表>, unmatched_rules = {...}, unmatched_sources = {...} }
|
||||||
|
-- 硬错误用 `return nil, "原因"`,内核据此拒绝保存/记录 ERROR。
|
||||||
|
local function normalizeURL(u)
|
||||||
|
u = tostring(u or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
u = u:gsub("/+$", "")
|
||||||
|
return u:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function matchSources(pattern, sources)
|
||||||
|
local out = {}
|
||||||
|
if pattern == "*" then
|
||||||
|
for _, s in ipairs(sources or {}) do out[#out + 1] = s.name end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
local want = normalizeURL(pattern)
|
||||||
|
for _, s in ipairs(sources or {}) do
|
||||||
|
if normalizeURL(s.base_url) == want then out[#out + 1] = s.name end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- USD per million → USD per token. 与内核校验用的是同一口径(/1e6),
|
||||||
|
-- 数值范围与 Go 的 ParseFloatUSDPerM 一致(不允许负数/非数字)。
|
||||||
|
local function usdPerM(v)
|
||||||
|
local s = tostring(v == nil and "" or v)
|
||||||
|
s = s:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
local n = tonumber(s)
|
||||||
|
if n == nil then return nil, "not a number: \"" .. tostring(v) .. "\"" end
|
||||||
|
if n < 0 then return nil, "negative price: \"" .. tostring(v) .. "\"" end
|
||||||
|
return n / 1e6, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- peak 窗口的**唯一**合法形状:{ multiplier, windows = { { days, hours } } }。
|
||||||
|
-- 早期部署过的 {start,end,weekdays} 没有任何读取方(peak 静默按非高峰计价)。
|
||||||
|
local function compilePeak(pk)
|
||||||
|
local win = {}
|
||||||
|
if type(pk.weekdays) == "table" and #pk.weekdays > 0 then
|
||||||
|
local days = {}
|
||||||
|
for i, d in ipairs(pk.weekdays) do days[i] = d end
|
||||||
|
win.days = days
|
||||||
|
end
|
||||||
|
if type(pk.hours) == "table" and #pk.hours > 0 then
|
||||||
|
local hours = {}
|
||||||
|
for i, h in ipairs(pk.hours) do hours[i] = { h[1], h[2] } end
|
||||||
|
win.hours = hours
|
||||||
|
end
|
||||||
|
local out = { multiplier = tonumber(pk.multiplier) or 1 }
|
||||||
|
if next(win) ~= nil then out.windows = { win } end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- long_context 的边际价:绝对值(USD/M 字符串)优先,其次倍数 × 基础价。
|
||||||
|
-- 两者都缺 ⇒ 报错(一个"没有价格的阶梯"是静默 no-op,写它的人一定有意图)。
|
||||||
|
local function longContextRate(lc, basePerToken)
|
||||||
|
local prompt = tostring(lc.prompt == nil and "" or lc.prompt)
|
||||||
|
prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
local mult = tonumber(lc.multiplier) or 0
|
||||||
|
if prompt ~= "" then
|
||||||
|
local v, err = usdPerM(prompt)
|
||||||
|
if not v then return nil, err end
|
||||||
|
return v, nil
|
||||||
|
end
|
||||||
|
if mult > 0 then return basePerToken * mult, nil end
|
||||||
|
return nil, "needs either prompt or multiplier"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyRule(prices, rule, srcName)
|
||||||
|
local mode = rule.mode
|
||||||
|
if mode == "free" or mode == "subscription" then
|
||||||
|
-- 显式零价:这里 0 本身就是语义(不是"未定价")。
|
||||||
|
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
|
||||||
|
if mode == "subscription" then
|
||||||
|
if prices.subscriptions == nil then prices.subscriptions = {} end
|
||||||
|
local cur = tostring(rule.currency == nil and "" or rule.currency)
|
||||||
|
if cur == "" then cur = "USD" end
|
||||||
|
prices.subscriptions[srcName] = {
|
||||||
|
monthly = tonumber(rule.subscription) or 0, currency = cur,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if mode == "unpriced" then
|
||||||
|
-- 刻意不写源条目:让 unpriced_reqs/unpriced_models 抓住它。
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if mode == "token" then
|
||||||
|
if type(rule.models) ~= "table" or next(rule.models) == nil then
|
||||||
|
return nil, string.format("rule for url %q: mode token requires at least one model",
|
||||||
|
tostring(rule.url))
|
||||||
|
end
|
||||||
|
for modelID, t in pairs(rule.models) do
|
||||||
|
local prompt, perr = usdPerM(t.prompt)
|
||||||
|
if not prompt then return nil, string.format("model %q prompt: %s", tostring(modelID), perr) end
|
||||||
|
local completion, cerr = usdPerM(t.completion)
|
||||||
|
if not completion then return nil, string.format("model %q completion: %s", tostring(modelID), cerr) end
|
||||||
|
local entry = { prompt = prompt, completion = completion }
|
||||||
|
if t.cache_discount ~= nil then entry.cache_discount = tonumber(t.cache_discount) end
|
||||||
|
-- reasoning 可选,且**缺席**而非 0:写 0 等于"思考免费",会静默少报
|
||||||
|
-- 每一个把 reasoning 按输出计价的 provider。
|
||||||
|
local reasoning = tostring(t.reasoning == nil and "" or t.reasoning)
|
||||||
|
reasoning = reasoning:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if reasoning ~= "" then
|
||||||
|
local rp, rerr = usdPerM(reasoning)
|
||||||
|
if not rp then return nil, string.format("model %q reasoning: %s", tostring(modelID), rerr) end
|
||||||
|
entry.reasoning_price = rp
|
||||||
|
end
|
||||||
|
if type(t.long_context) == "table" then
|
||||||
|
local above = tonumber(t.long_context.above) or 0
|
||||||
|
if above <= 0 then
|
||||||
|
return nil, string.format("model %q long_context: above must be > 0", tostring(modelID))
|
||||||
|
end
|
||||||
|
local rate, lerr = longContextRate(t.long_context, prompt)
|
||||||
|
if not rate then
|
||||||
|
return nil, string.format("model %q long_context: %s", tostring(modelID), lerr)
|
||||||
|
end
|
||||||
|
entry.long_context = { above = above, prompt = rate }
|
||||||
|
end
|
||||||
|
if type(rule.peak) == "table" then entry.peak = compilePeak(rule.peak) end
|
||||||
|
prices.models[modelID] = entry
|
||||||
|
end
|
||||||
|
-- ★ token 规则**不写任何源条目**(模型维度只发布模型价)。
|
||||||
|
--
|
||||||
|
-- 此前写 `{per_request:0}` 或 `{prompt:0,completion:0}` 都是为了"标记该源
|
||||||
|
-- 已定价",但 priceFor() 让源层**无条件覆盖**模型层的 prompt/completion:
|
||||||
|
-- * `{prompt:0,completion:0}` ⇒ 规则刚发布的模型价被清零(issue #1 本体);
|
||||||
|
-- * 只写 `{per_request:0}` ⇒ 模型价没被清零,但同源上**未列入 rule.Models**
|
||||||
|
-- 的模型仍被评为"已定价"、静默计 0,unpriced 计数依旧不涨。
|
||||||
|
-- 第二条仍然是"静默少报",只是窄一点。所以统一收口:token 规则不产生源条目,
|
||||||
|
-- 未列价的模型落到 unpriced 统计里(可见)。真正"边际成本为零"用
|
||||||
|
-- mode=free / mode=subscription —— 它们写的是有意为之的零价标记。
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return nil, string.format("unknown mode %q", tostring(mode))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compilePrices(profile, sources, lenient)
|
||||||
|
if type(profile) ~= "table" then return nil, "no billing profile" end
|
||||||
|
sources = sources or {}
|
||||||
|
local prices = {
|
||||||
|
currency = "USD",
|
||||||
|
default = { prompt = 0, completion = 0, per_request = 0 },
|
||||||
|
sources = {}, models = {}, keys = {},
|
||||||
|
}
|
||||||
|
local rules = type(profile.rules) == "table" and profile.rules or {}
|
||||||
|
-- currency 是 profile 级声明:第一条带 currency 的规则生效。
|
||||||
|
for i = 1, #rules do
|
||||||
|
local c = rules[i].currency
|
||||||
|
if type(c) == "string" and c ~= "" then prices.currency = c break end
|
||||||
|
end
|
||||||
|
local matched = {}
|
||||||
|
local unmatched_rules = {}
|
||||||
|
for i = 1, #rules do
|
||||||
|
local rule = rules[i]
|
||||||
|
local url = tostring(rule.url == nil and "" or rule.url)
|
||||||
|
local targets = matchSources(url, sources)
|
||||||
|
if #targets == 0 and url ~= "*" then
|
||||||
|
if lenient then
|
||||||
|
unmatched_rules[#unmatched_rules + 1] = url
|
||||||
|
else
|
||||||
|
return nil, string.format("rule url %q matches no configured source base_url", url)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, srcName in ipairs(targets) do
|
||||||
|
-- 首条命中优先:两条规则匹配同一个源是歧义,后面的不能悄悄覆盖前面的。
|
||||||
|
if not matched[srcName] then
|
||||||
|
matched[srcName] = true
|
||||||
|
local ok, err = applyRule(prices, rule, srcName)
|
||||||
|
if not ok then return nil, err end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- profile 的 default 策略:只影响**没有规则命中**的源。
|
||||||
|
--
|
||||||
|
-- `free` = 运营声明"其余源一律免费" ⇒ 必须被识别为已定价,否则它们会一直计入
|
||||||
|
-- unpriced,告警变成噪音;标记用 `{per_request:0}`(只标记、不碰 token 价,
|
||||||
|
-- 否则任何通配符价目表都会被它清零,等于同一个 bug 换个入口复现)。
|
||||||
|
-- `unpriced`(或缺省)= 不写条目,让插件的 unpriced 计数看得见它们。
|
||||||
|
--
|
||||||
|
-- 这个策略以前只被校验、却被写进一个没人读的 `default_mode` 键 ⇒
|
||||||
|
-- `default: free` 与 `default: unpriced` 运行时完全等价。
|
||||||
|
if profile.default == "free" then
|
||||||
|
for _, src in ipairs(sources) do
|
||||||
|
if not matched[src.name] and prices.sources[src.name] == nil then
|
||||||
|
prices.sources[src.name] = { per_request = 0 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local unmatched_sources = {}
|
||||||
|
for _, src in ipairs(sources) do
|
||||||
|
if not matched[src.name] then unmatched_sources[#unmatched_sources + 1] = src.name end
|
||||||
|
end
|
||||||
|
return { prices = prices, unmatched_rules = unmatched_rules, unmatched_sources = unmatched_sources }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 内核入口:profile 与源的 base_url 都是**数据**,插件不认识 config.yaml。
|
||||||
|
plugin.compile_prices = function(args)
|
||||||
|
args = args or {}
|
||||||
|
local out, err = compilePrices(args.profile, args.sources, args.lenient and true or false)
|
||||||
|
-- 错误用**表里的 error 字段**回传,而不是第二个返回值:内核桥的
|
||||||
|
-- `Call` 只把第一个返回值解码成数据(第二个返回值的错误通道在插件导出上
|
||||||
|
-- 并不可靠),用 {error=...} 是本仓库其它插件导出一致的约定。
|
||||||
|
if not out then return { error = tostring(err) } end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
plugin.exports = plugin.exports or {}
|
||||||
|
plugin.exports[#plugin.exports + 1] = "compile_prices"
|
||||||
|
|
||||||
-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh
|
-- 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
|
-- token, which is what DeepSeek/Qwen/Kimi and most others charge. It can be
|
||||||
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
|
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
|
||||||
@ -241,17 +463,6 @@ local function priceFor(payload)
|
|||||||
if kp.peak ~= nil then out.peak = kp.peak end
|
if kp.peak ~= nil then out.peak = kp.peak end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- profile-level default for sources no rule matched.
|
|
||||||
--
|
|
||||||
-- compile.go has always written `default_mode` ("free" | "unpriced") into the
|
|
||||||
-- prices table, but NOTHING read it — so `default: free` and
|
|
||||||
-- `default: unpriced` behaved identically at runtime (both fell through to
|
|
||||||
-- prices.default's zeros and counted as unpriced debt). That is a second
|
|
||||||
-- silent under-report (GitCode issue #1 follow-up). "free" is a DELIBERATE
|
|
||||||
-- zero, so it must be marked priced; anything else stays countable debt.
|
|
||||||
if not out.priced and p.default_mode == "free" then
|
|
||||||
out.priced = true
|
|
||||||
end
|
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user