fix(pricing): validateTieredBands 按下界排序判档位相邻 + 修同起 0 漏检
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
)
|
||||
@@ -299,17 +300,32 @@ func (c modelTokenCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// validateTieredBands 阶梯分档校验:档位区间相邻不重叠(下档 max+1 = 上档 min)
|
||||
// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。
|
||||
// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。
|
||||
func validateTieredBands(rules []modelRule) error {
|
||||
for i := 0; i < len(rules); i++ {
|
||||
for j := i + 1; j < len(rules); j++ {
|
||||
a, b := rules[i].Match, rules[j].Match
|
||||
if a == nil || b == nil || a.InputLengthMax == 0 || b.InputLengthMin == 0 {
|
||||
continue // 无档位约束的规则不参与重叠校验
|
||||
}
|
||||
if b.InputLengthMin <= a.InputLengthMax {
|
||||
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
|
||||
}
|
||||
var bands []modelRule
|
||||
for _, r := range rules {
|
||||
if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) {
|
||||
bands = append(bands, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(bands, func(i, j int) bool {
|
||||
return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin
|
||||
})
|
||||
var prevMax int64 = -1
|
||||
openEnded := false
|
||||
for _, r := range bands {
|
||||
m := r.Match
|
||||
if openEnded {
|
||||
return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位")
|
||||
}
|
||||
if m.InputLengthMin <= prevMax {
|
||||
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
|
||||
}
|
||||
if m.InputLengthMax == 0 {
|
||||
openEnded = true // 上不封顶,之后不可再有长度档
|
||||
} else {
|
||||
prevMax = m.InputLengthMax
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,189 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// 阶梯命中:思考≤32000 → input 0.6/output 3.6;>32000 → 0.9/5.4
|
||||
const modelTokenTieredJSON = `{
|
||||
"unit":"per_1M","tiered":true,
|
||||
"rules":[
|
||||
{"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000,"mediaType":"text"},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}},
|
||||
{"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001,"mediaType":"text"},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}}
|
||||
]}`
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func ptr(b bool) *bool { return &b }
|
||||
|
||||
func TestModelTokenCalculator_TieredMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
func TestValidateTieredBandsAdjacentOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}},
|
||||
{Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}},
|
||||
}
|
||||
// 第一档:prompt 20000 → (0.6*20000 + 3.6*1000)/1e6 = 0.0156 → ceil → 0.02
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.02 {
|
||||
t.Fatalf("tier1 got %v want 0.02", got)
|
||||
}
|
||||
// 第二档:prompt 50000 → (0.9*50000 + 5.4*1000)/1e6 = 0.0504 → ceil → 0.06
|
||||
got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.06 {
|
||||
t.Fatalf("tier2 got %v want 0.06", got)
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("相邻档位应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTokenCalculator_NoMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
func TestValidateTieredBandsSameStartRejected(t *testing.T) {
|
||||
// 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}},
|
||||
}
|
||||
// thinking=false 不在任一档 → 无匹配报错
|
||||
_, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "text", Thinking: ptr(false)})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-match error")
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("两个同起 0 的档位必须判重叠")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTokenCalculator_CacheHit(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) {
|
||||
// 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检)
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}},
|
||||
}
|
||||
// (2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 1 + 0.1 + 1 = 2.1
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 2.1 {
|
||||
t.Fatalf("cache got %v want 2.1", got)
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("重叠必须与提交顺序无关地被检出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelUnitCalculator(t *testing.T) {
|
||||
// 音频按时长(分钟):90 秒 × 2 元/分钟 = 3
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 90})
|
||||
if err != nil || got != 3 {
|
||||
t.Fatalf("per_minute got %v want 3, err %v", got, err)
|
||||
}
|
||||
// 音频按字数:10000 字 × 0.0005 = 5(独立 per_char 规则)
|
||||
c2 := modelUnitCalculator{base: 1, usage: usageCharCount}
|
||||
rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000})
|
||||
if err != nil || got != 5 {
|
||||
t.Fatalf("per_char got %v want 5, err %v", got, err)
|
||||
}
|
||||
// 图片按张数×分辨率
|
||||
c3 := modelUnitCalculator{base: 1, usage: usageImageCount}
|
||||
rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[
|
||||
{"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}},
|
||||
{"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"})
|
||||
if err != nil || got != 1.5 {
|
||||
t.Fatalf("per_1 got %v want 1.5, err %v", got, err)
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("上不封顶在最后应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerPeriodCalculator(t *testing.T) {
|
||||
c := perPeriodCalculator{}
|
||||
rules, err := c.validate(`{"period":"year","price":1999}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
{Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}},
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{})
|
||||
if err != nil || got != 1999 {
|
||||
t.Fatalf("per_period got %v want 1999, err %v", got, err)
|
||||
}
|
||||
// 非法周期报错
|
||||
if _, err = c.validate(`{"period":"month","price":100}`); err == nil {
|
||||
t.Fatal("expected invalid period error")
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("上不封顶档之后不能再有长度档")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcChargeBySubjectType(t *testing.T) {
|
||||
// workflow per_second 走整秒向上取整计算器:ceil(2.2)=3 × 0.9 = 2.7
|
||||
got, err := calcCharge("workflow", "per_second", `{"unitPrice":0.9}`, &ChargeUsage{DurationSec: 2.2})
|
||||
if err != nil || got != 2.7 {
|
||||
t.Fatalf("workflow per_second got %v want 2.7, err %v", got, err)
|
||||
func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) {
|
||||
// thinking 兜底档无长度区间,不应参与重叠校验
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{Thinking: boolPtr(false)}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
}
|
||||
// model per_second 走精确秒单位计算器:2.2 × 0.9 = 1.98
|
||||
got, err = calcCharge("model", "per_second", `{"unit":"per_second","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.9}}]}`, &ChargeUsage{DurationSec: 2.2})
|
||||
if err != nil || got != 1.98 {
|
||||
t.Fatalf("model per_second got %v want 1.98, err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(token 计算器)
|
||||
func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(单位计算器)
|
||||
func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// P2:modelTokenCalculator.validate 拒绝 unit 与实例 base 不匹配的配置
|
||||
func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) {
|
||||
// per_1M 实例(base=1e6)配 per_1K 单位 → 校验失败
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit")
|
||||
}
|
||||
// per_1K 实例(base=1000)配 per_1M 单位 → 校验失败
|
||||
c2 := modelTokenCalculator{base: 1000}
|
||||
if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit")
|
||||
}
|
||||
// 匹配时不报错
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// M3:cached > prompt 时金额不为负(promptInput 钳为 0)
|
||||
func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
// cached 3000 > prompt 1000 → promptInput 钳 0:0 + 3000/1000*0.1 + 0 = 0.3
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got < 0 {
|
||||
t.Fatalf("got negative amount %v", got)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("got %v want 0.3", got)
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("无长度规则不应干扰: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user