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

image 与 per_request 模式的层级按 tier_label (1K/2K/4K) 匹配,
不依赖 min/max token 范围, 多个层级共用 min=0/max=null 是预期形态。
原校验器一律按 token 上下文分段处理, 新增第二条图片层级时会被
"无上限区间只能是最后一个" 误拦, 导致 OpenAI gpt-image 等模型
无法保存按次定价。

validateIntervals 新增 mode 参数, image / per_request 模式跳过
区间重叠与 last-unlimited 检查, 保留单条 min/max 自洽与价格非负
校验。token 模式行为不变。
This commit is contained in:
name 2026-05-14 23:48:47 +08:00
parent 18790386a7
commit b936925c8a
3 changed files with 94 additions and 3 deletions

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')