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:
parent
b936925c8a
commit
ff6f1640c4
@ -262,10 +262,17 @@ func deepCopyFeaturesConfig(src map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateIntervals 校验区间列表的合法性。
|
// ValidateIntervals 校验区间列表的合法性。
|
||||||
// 规则:MinTokens >= 0;MaxTokens 若非 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 >= 0;MaxTokens 若非 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user