fix(channels): 后端按次/图片计费跳过 token 区间重叠校验

前端在上一个 commit 已对 image / per_request 模式跳过 unbounded-last
和重叠检查, 但保存时后端仍按 token 语义校验, 导致添加第二个图片层级
时报错:

  invalid pricing intervals for platform 'openai' models
  [gpt-image-2 gpt-image-1.5 gpt-image-1]:
    interval #1: unbounded interval (max_tokens=null) must be the last one

ValidateIntervals 加 mode 参数, 与前端校验逻辑对齐:
- token 模式行为不变 (区间重叠 / last-unlimited 仍校验)
- per_request / image 模式跳过区间重叠和 last-unlimited 检查,
  保留单条 min/max 自洽校验与价格非负校验。

调用方 validatePricingIntervals 把 pricing.BillingMode 透给校验器。
既有单测全部加上 BillingModeToken 显式参数, 新增 3 个 image 模式用例
(允许多条 unbounded / 仍拒绝负价 / 仍拒绝 max <= min)。
This commit is contained in:
name 2026-05-15 13:14:07 +08:00
parent b936925c8a
commit ff6f1640c4
3 changed files with 58 additions and 15 deletions

View File

@ -262,10 +262,17 @@ func deepCopyFeaturesConfig(src map[string]any) map[string]any {
} }
// ValidateIntervals 校验区间列表的合法性。 // ValidateIntervals 校验区间列表的合法性。
// 规则MinTokens >= 0MaxTokens 若非 nil 则 > 0 且 > MinTokens //
// 所有价格字段 >= 0区间按 MinTokens 排序后无重叠((min, max] 语义); // mode 决定区间语义:
// 无界区间MaxTokens=nil必须是最后一个。间隙允许回退默认价格 // - BillingModeToken含空值区间是上下文 token 数分段 (min, max]
func ValidateIntervals(intervals []PricingInterval) error { // 按 MinTokens 排序后无重叠无界区间MaxTokens=nil必须是最后一个。
// - BillingModePerRequest / BillingModeImage区间是按 tier_label
// (1K/2K/4K 等) 分层,匹配走 label 不依赖 min/max因此跳过区间重叠
// 与 last-unlimited 校验仅做单条字段自洽min/max/价格非负)检查。
//
// 通用规则MinTokens >= 0MaxTokens 若非 nil 则 > 0 且 > MinTokens
// 所有价格字段 >= 0。
func ValidateIntervals(intervals []PricingInterval, mode BillingMode) error {
if len(intervals) == 0 { if len(intervals) == 0 {
return nil return nil
} }
@ -280,6 +287,11 @@ func ValidateIntervals(intervals []PricingInterval) error {
return err return err
} }
} }
// per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
if mode == BillingModePerRequest || mode == BillingModeImage {
return nil
}
return validateIntervalOverlap(sorted) return validateIntervalOverlap(sorted)
} }

View File

@ -951,7 +951,7 @@ func validateNoConflictingMappings(mapping map[string]map[string]string) error {
func validatePricingIntervals(pricingList []ChannelModelPricing) error { func validatePricingIntervals(pricingList []ChannelModelPricing) error {
for _, pricing := range pricingList { for _, pricing := range pricingList {
if err := ValidateIntervals(pricing.Intervals); err != nil { if err := ValidateIntervals(pricing.Intervals, pricing.BillingMode); err != nil {
return infraerrors.BadRequest( return infraerrors.BadRequest(
"INVALID_PRICING_INTERVALS", "INVALID_PRICING_INTERVALS",
fmt.Sprintf("invalid pricing intervals for platform '%s' models %v: %v", fmt.Sprintf("invalid pricing intervals for platform '%s' models %v: %v",

View File

@ -311,8 +311,8 @@ func TestChannelClone_EdgeCases(t *testing.T) {
// --- ValidateIntervals --- // --- ValidateIntervals ---
func TestValidateIntervals_Empty(t *testing.T) { func TestValidateIntervals_Empty(t *testing.T) {
require.NoError(t, ValidateIntervals(nil)) require.NoError(t, ValidateIntervals(nil, BillingModeToken))
require.NoError(t, ValidateIntervals([]PricingInterval{})) require.NoError(t, ValidateIntervals([]PricingInterval{}, BillingModeToken))
} }
func TestValidateIntervals_ValidIntervals(t *testing.T) { func TestValidateIntervals_ValidIntervals(t *testing.T) {
@ -357,7 +357,7 @@ func TestValidateIntervals_ValidIntervals(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
require.NoError(t, ValidateIntervals(tt.intervals)) require.NoError(t, ValidateIntervals(tt.intervals, BillingModeToken))
}) })
} }
} }
@ -366,7 +366,7 @@ func TestValidateIntervals_NegativeMinTokens(t *testing.T) {
intervals := []PricingInterval{ intervals := []PricingInterval{
{MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "min_tokens") require.Contains(t, err.Error(), "min_tokens")
require.Contains(t, err.Error(), ">= 0") require.Contains(t, err.Error(), ">= 0")
@ -376,7 +376,7 @@ func TestValidateIntervals_MaxTokensZero(t *testing.T) {
intervals := []PricingInterval{ intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> 0") require.Contains(t, err.Error(), "> 0")
@ -386,7 +386,7 @@ func TestValidateIntervals_MaxLessThanMin(t *testing.T) {
intervals := []PricingInterval{ intervals := []PricingInterval{
{MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> min_tokens") require.Contains(t, err.Error(), "> min_tokens")
@ -396,7 +396,7 @@ func TestValidateIntervals_MaxEqualsMin(t *testing.T) {
intervals := []PricingInterval{ intervals := []PricingInterval{
{MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> min_tokens") require.Contains(t, err.Error(), "> min_tokens")
@ -407,7 +407,7 @@ func TestValidateIntervals_NegativePrice(t *testing.T) {
intervals := []PricingInterval{ intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice}, {MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "input_price") require.Contains(t, err.Error(), "input_price")
require.Contains(t, err.Error(), ">= 0") require.Contains(t, err.Error(), ">= 0")
@ -418,7 +418,7 @@ func TestValidateIntervals_OverlappingIntervals(t *testing.T) {
{MinTokens: 0, MaxTokens: testPtrInt(200), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 0, MaxTokens: testPtrInt(200), InputPrice: testPtrFloat64(1e-6)},
{MinTokens: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)}, {MinTokens: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "overlap") require.Contains(t, err.Error(), "overlap")
} }
@ -428,12 +428,43 @@ func TestValidateIntervals_UnboundedNotLast(t *testing.T) {
{MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
{MinTokens: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)}, {MinTokens: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)},
} }
err := ValidateIntervals(intervals) err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "unbounded") require.Contains(t, err.Error(), "unbounded")
require.Contains(t, err.Error(), "last") require.Contains(t, err.Error(), "last")
} }
func TestValidateIntervals_ImageModeAllowsMultipleUnboundedTiers(t *testing.T) {
// image / per_request 按 tier_label 匹配,多条 min=0/max=nil 是合法形态。
intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
{MinTokens: 0, MaxTokens: nil, TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.06)},
{MinTokens: 0, MaxTokens: nil, TierLabel: "4K", PerRequestPrice: testPtrFloat64(0.08)},
}
require.NoError(t, ValidateIntervals(intervals, BillingModeImage))
require.NoError(t, ValidateIntervals(intervals, BillingModePerRequest))
}
func TestValidateIntervals_ImageModeStillRejectsNegativePrice(t *testing.T) {
// image 模式只跳过区间重叠校验,单条字段自洽(价格非负)仍要校验。
intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(-1)},
}
err := ValidateIntervals(intervals, BillingModeImage)
require.Error(t, err)
require.Contains(t, err.Error(), "must be >= 0")
}
func TestValidateIntervals_ImageModeStillRejectsBadMaxTokens(t *testing.T) {
// image 模式仍校验 max <= min 这种单条不合法。
intervals := []PricingInterval{
{MinTokens: 100, MaxTokens: testPtrInt(50), TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
}
err := ValidateIntervals(intervals, BillingModeImage)
require.Error(t, err)
require.Contains(t, err.Error(), "must be > min_tokens")
}
func TestSupportedModels_ExactKeysAndPricing(t *testing.T) { func TestSupportedModels_ExactKeysAndPricing(t *testing.T) {
ch := &Channel{ ch := &Channel{
ModelPricing: []ChannelModelPricing{ ModelPricing: []ChannelModelPricing{