mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
GitCode issue #1:任何按 `mode: token` 且 URL 非通配符的规则定价的源,计费恒为 0,
且 `unpriced_reqs` 也不增长 —— 完全静默。
## 真因
- `compile.go` 给这类规则写源条目 `{prompt:0, completion:0, per_request:0}`,
本意只是"标记该源已定价"(同源未列入价目表的模型不算 unpriced);
- `billing.lua` 的 priceFor() 源层在模型层之后、且 `sp.prompt ~= nil` 即覆盖;
- 两者叠加 ⇒ 模型价被清成 0,而 `out.priced` 仍为 true ⇒ 连未定价计数都不涨。
## 修法
1. `compile.go`:token 规则的源条目只写 `{per_request: 0}`(只是标记,不参与 token
计价)。`free` / `subscription` 保留全零(那里 0 本身就是语义)。
2. `billing.lua`:
- `priceFor()` 的 `out` 补 `local`(此前是 VM 级全局变量,跨请求残留);
- 实现 `profiles[].default` 的 `free|unpriced` 语义:`free` = 刻意免费(不进
unpriced),`unpriced`(默认)= 未定价债务。此前 compile.go 写 `default_mode`
但**无人读**,两种取值运行时完全等价。
3. `core.go`:应用 profile 时把 `unmatched_sources` 打进日志(WARNING 列出没有
规则命中的源)。该字段此前只写不读,是第二个静默少报点。
4. `docs/plugins.md`:token 价优先级改为与实际实现一致的
`keys > sources > models > default`,并写明"标记已定价只能用 per_request:0"。
## 判据(都先红后绿)
- `internal/billing`:token 规则的源条目**不含** prompt/completion;free/subscription
仍保留显式 0。
- `internal/lua`:同源同模型下旧形状 cost=0(缺陷本身)、新形状 cost=0.75 且
unpriced_reqs=0;未列入价目表的模型仍算"已定价";default_mode 三态。
- `internal/gateway`:`billing_rules_api_test` 的单位断言原本读
`cc["models"]`(恒 false,整块被跳过——从未执行过),改为 `prices["models"]`,
并新增"源条目不得带 token 价"断言。
## 顺带修掉一个"永不执行"的判据
`discover_mutate_test.go` 的变异锚点写成 6 个空格,而 `discover.go` 已是 2 个空格
⇒ 该变异每次都 `anchor not found`,变异 campaign 长期红着。修正锚点后,
"嵌套 metadata 不再读取"这条变异真的被执行并被测试 KILL。
292 lines
11 KiB
Go
292 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|