mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
fix(billing): 通配符 token 规则不再把付费源标成「已定价 0」+ 迁移现有价格为规则
## 迁移后立刻发现的设计缺陷
把 .billing.state.json 里的现行价格迁成 URL 规则后(免费源 4 个 + 一个 `*`
模型价目表),注入插件的价格表里出现了 21 个 source 条目,全部 {0,0,0}——
包括 commandcode、alittokenplan 这些**付费**源。
原因在 Compile 的 token 分支:规则匹配到的每个 source 都会补一个 {0,0,0}
条目,注释写的理由是「否则该 URL 上没列进 rule.Models 的模型会掉回默认 0」。
但 `*` 匹配所有 source,于是这条为「特定 URL」设计的兜底变成了「给所有源盖
已定价 0 的章」。
后果正是插件本身要防的那个失效:priceFor 只要 source 有条目就置 priced=true,
priced=true 的请求不进 unpriced_reqs。所以付费源上未列出的模型全部记成
「已定价 $0」,账单看着加得起来,实际静默少算——commandcode 的 DeepSeek
正是这种情况(54k 请求的 deepseek/deepseek-v4.1-flash 在旧 sidecar 里
压根没定价)。
## 修法
通配符规则不再补 source 条目。`*` 的语义是模型目录:「这几个模型 id wherever
从哪来都这个价」,它不是「这些源都免费」。模型查表 p.models[payload.model]
本身就会把列出的模型标为已定价,所以通配符去掉 source 条目不丢任何东西。
特定 URL 的 token 规则保留 source 条目(它确实为该 URL 声明了定价)。
## 判据(2 条 + 3 个变异)
- 通配符不得标记任何未显式声明的源为已定价(点名 commandcode 场景)
- 特定 URL 规则仍必须标记自己的源(反向约束,防止把兜底整体删掉)
变异 M1(通配符也标记)、M2(只有通配符标记)、M3d(模型价发布到错 key)
均被捕获。M3 前两版「漏放」是我的变异脚本改坏了编译(unused variable),
grep 匹配不到 "--- FAIL"——和之前一样的工具陷阱,判据本身没问题。
## 线上状态
config.yaml 已写入 billing 段(active: current,5 条规则,无 warning),
生效价格表只把 4 个免费源标为 priced 0,付费源保持未标记⇒其未列出模型会
正确计入 unpriced 而非静默 0 元。
真实请求验证:3 个 deepseek-v4.1-flash 流式请求 200,计费从 0.56679
涨到 0.56702,走的是新规则注入的价格。全量测试连跑 5 次全绿。
This commit is contained in:
@ -200,13 +200,24 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
|
|||||||
}
|
}
|
||||||
models[modelID] = entry
|
models[modelID] = entry
|
||||||
}
|
}
|
||||||
// The source itself needs an entry too, otherwise priceFor() marks the
|
// A specific-URL token rule needs a source entry so a model served
|
||||||
// request priced only when the model happens to be listed, and a model
|
// from THIS url but absent from `rule.Models` is still recognised as
|
||||||
// served from this URL but absent from the rule would be billed at the
|
// belonging to a priced provider.
|
||||||
// DEFAULT (zero) rate — indistinguishable from unpriced in the totals.
|
//
|
||||||
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
|
||||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
// says "these model ids have these prices, wherever they come from" —
|
||||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
// 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 {
|
||||||
|
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||||
|
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
88
internal/billing/wildcard_test.go
Normal file
88
internal/billing/wildcard_test.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user