Merge pull request #2473 from is7Qin/fix/image-tier-validation

fix(channels): 按次/图片计费模式下区间校验跳过 token 上下文重叠规则
This commit is contained in:
Wesley Liddick 2026-05-19 14:28:33 +08:00 committed by GitHub
commit c65522641d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 152 additions and 18 deletions

View File

@ -262,10 +262,17 @@ func deepCopyFeaturesConfig(src map[string]any) map[string]any {
}
// ValidateIntervals 校验区间列表的合法性。
// 规则MinTokens >= 0MaxTokens 若非 nil 则 > 0 且 > MinTokens
// 所有价格字段 >= 0区间按 MinTokens 排序后无重叠((min, max] 语义);
// 无界区间MaxTokens=nil必须是最后一个。间隙允许回退默认价格
func ValidateIntervals(intervals []PricingInterval) error {
//
// mode 决定区间语义:
// - BillingModeToken含空值区间是上下文 token 数分段 (min, max]
// 按 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 {
return nil
}
@ -280,6 +287,11 @@ func ValidateIntervals(intervals []PricingInterval) error {
return err
}
}
// per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
if mode == BillingModePerRequest || mode == BillingModeImage {
return nil
}
return validateIntervalOverlap(sorted)
}

View File

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

View File

@ -311,8 +311,8 @@ func TestChannelClone_EdgeCases(t *testing.T) {
// --- ValidateIntervals ---
func TestValidateIntervals_Empty(t *testing.T) {
require.NoError(t, ValidateIntervals(nil))
require.NoError(t, ValidateIntervals([]PricingInterval{}))
require.NoError(t, ValidateIntervals(nil, BillingModeToken))
require.NoError(t, ValidateIntervals([]PricingInterval{}, BillingModeToken))
}
func TestValidateIntervals_ValidIntervals(t *testing.T) {
@ -357,7 +357,7 @@ func TestValidateIntervals_ValidIntervals(t *testing.T) {
for _, tt := range tests {
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{
{MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "min_tokens")
require.Contains(t, err.Error(), ">= 0")
@ -376,7 +376,7 @@ func TestValidateIntervals_MaxTokensZero(t *testing.T) {
intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> 0")
@ -386,7 +386,7 @@ func TestValidateIntervals_MaxLessThanMin(t *testing.T) {
intervals := []PricingInterval{
{MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> min_tokens")
@ -396,7 +396,7 @@ func TestValidateIntervals_MaxEqualsMin(t *testing.T) {
intervals := []PricingInterval{
{MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "max_tokens")
require.Contains(t, err.Error(), "> min_tokens")
@ -407,7 +407,7 @@ func TestValidateIntervals_NegativePrice(t *testing.T) {
intervals := []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "input_price")
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: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
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: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)},
}
err := ValidateIntervals(intervals)
err := ValidateIntervals(intervals, BillingModeToken)
require.Error(t, err)
require.Contains(t, err.Error(), "unbounded")
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) {
ch := &Channel{
ModelPricing: []ChannelModelPricing{

View File

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { validateIntervals, type IntervalFormEntry } from '../types'
function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
return {
min_tokens: 0,
max_tokens: null,
tier_label: '',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: null,
sort_order: 0,
...over,
}
}
describe('validateIntervals', () => {
describe('token mode', () => {
it('rejects unbounded interval that is not last', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/无上限/)
})
it('accepts unbounded interval at the end', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ min_tokens: 0, max_tokens: 200000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: null, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toBeNull()
})
it('rejects overlapping intervals', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ min_tokens: 0, max_tokens: 250000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/重叠/)
})
it('defaults mode to token when omitted', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 100, max_tokens: 200, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals)).toMatch(/无上限/)
})
})
describe('image / per_request mode', () => {
it('allows multiple unbounded tiers identified by label', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', per_request_price: 0.04 }),
makeInterval({ tier_label: '2K', per_request_price: 0.06 }),
makeInterval({ tier_label: '4K', per_request_price: 0.08 }),
]
expect(validateIntervals(intervals, 'image')).toBeNull()
expect(validateIntervals(intervals, 'per_request')).toBeNull()
})
it('still rejects negative prices', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', per_request_price: -1 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/不能为负数/)
})
it('still rejects max <= min on a single tier', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', min_tokens: 100, max_tokens: 50, per_request_price: 0.04 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/必须大于/)
})
})
})

View File

@ -115,8 +115,17 @@ export function findModelConflict(models: string[]): [string, string] | null {
// ── 区间校验 ──────────────────────────────────────────────
/** 校验区间列表的合法性,返回错误消息;通过则返回 null */
export function validateIntervals(intervals: IntervalFormEntry[]): string | null {
/** null
*
* mode
* - token token (min, max]
* - per_request / image tier_label 1K/2K/4K label
* min/max / last-unlimited
*/
export function validateIntervals(
intervals: IntervalFormEntry[],
mode: BillingMode = 'token',
): string | null {
if (!intervals || intervals.length === 0) return null
// 按 min_tokens 排序(不修改原数组)
@ -126,6 +135,9 @@ export function validateIntervals(intervals: IntervalFormEntry[]): string | null
const err = validateSingleInterval(sorted[i], i)
if (err) return err
}
// per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
if (mode !== 'token') return null
return checkIntervalOverlap(sorted)
}

View File

@ -1418,7 +1418,7 @@ async function handleSubmit() {
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (!entry.intervals || entry.intervals.length === 0) continue
const intervalErr = validateIntervals(entry.intervals)
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode)
if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')