模型广场:上下文档位统一标签形态并保证升序

- 阶梯表标签由计费层统一生成:有上限的档为「≤上限」、末档为「>下限」
  (达到阈值即进高档时用 < / ≥),不再沿用渠道区间的自定义 tier_label;
  合并同价段只看单价
- 前端档位按下限升序兜底展示,无标签时按同一形态生成
This commit is contained in:
feeeei
2026-08-24 10:50:52 +08:00
parent 377d1230fc
commit ecce0769c0
4 changed files with 55 additions and 73 deletions
@@ -119,11 +119,8 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
}
tiers = append(tiers, tier)
}
if legacy == nil {
applyIntervalContextLabels(tiers, resolved.Intervals)
}
tiers = mergeEqualContextTiers(tiers)
applyGeneratedContextLabels(tiers, plan)
applyContextTierLabels(tiers, plan)
basis := ContextPricingBasisWholeRequest
if legacy != nil {
@@ -330,35 +327,14 @@ func contextPricePtr(v *float64, explicit bool) *float64 {
return v
}
// applyIntervalContextLabels 把管理员在渠道区间上配置的 tier_label 带到对应档位。
func applyIntervalContextLabels(tiers []ContextPricingTier, intervals []PricingInterval) {
for i := range tiers {
for j := range intervals {
iv := &intervals[j]
if iv.TierLabel == "" || iv.MinTokens != tiers[i].MinTokens {
continue
}
if (iv.MaxTokens == nil) != (tiers[i].MaxTokens == nil) {
continue
}
if iv.MaxTokens != nil && *iv.MaxTokens != *tiers[i].MaxTokens {
continue
}
tiers[i].Label = iv.TierLabel
break
}
}
}
// mergeEqualContextTiers 合并相邻、四项单价相同且都没有管理员标签的段
// (倍率 ≤1 的目录、关闭阶梯等场景塌成单档)。
// mergeEqualContextTiers 合并相邻且四项单价相同的段(倍率 ≤1 的目录、关闭阶梯等场景塌成单档)。
func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier {
if len(tiers) < 2 {
return tiers
}
merged := make([]ContextPricingTier, 0, len(tiers))
for _, t := range tiers {
if n := len(merged); n > 0 && merged[n-1].Label == "" && t.Label == "" && sameContextPrices(merged[n-1], t) {
if n := len(merged); n > 0 && sameContextPrices(merged[n-1], t) {
merged[n-1].MaxTokens = t.MaxTokens
continue
}
@@ -383,25 +359,25 @@ func samePricePtr(a, b *float64) bool {
return math.Abs(*a-*b) <= scale*1e-9
}
// applyGeneratedContextLabels 给档位打标签:渠道区间沿用管理员配置的 tier_label;
// 目录阶梯/旧规则的两档按阈值生成(达到阈值即进高档时用 < / ≥)。
func applyGeneratedContextLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) {
// applyContextTierLabels 给多档阶梯打统一形态的标签:有上限的档为「≤上限」,
// 末档为「>下限」;档位按上下文升序,因此相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
// 目录阶梯/旧规则在"达到阈值即进高档"时改用 < / ≥ 表达阈值本身。
// 渠道区间上的 tier_label 不用于 token 档位(token 模式的管理表单不暴露该字段)。
func applyContextTierLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) {
if len(tiers) < 2 {
return
}
if plan.thresholdBound > 0 {
label := formatContextTokenCount(plan.threshold)
lowPrefix, highPrefix := "≤", ">"
if plan.thresholdInclusive {
lowPrefix, highPrefix = "<", "≥"
}
for i := range tiers {
switch {
case tiers[i].MaxTokens != nil && *tiers[i].MaxTokens == plan.thresholdBound:
tiers[i].Label = lowPrefix + label
case tiers[i].MinTokens == plan.thresholdBound:
tiers[i].Label = highPrefix + label
}
for i := range tiers {
t := &tiers[i]
switch {
case plan.thresholdInclusive && t.MaxTokens != nil && *t.MaxTokens == plan.thresholdBound:
t.Label = "<" + formatContextTokenCount(plan.threshold)
case plan.thresholdInclusive && t.MinTokens == plan.thresholdBound:
t.Label = "≥" + formatContextTokenCount(plan.threshold)
case t.MaxTokens != nil:
t.Label = "≤" + formatContextTokenCount(*t.MaxTokens)
default:
t.Label = ">" + formatContextTokenCount(t.MinTokens)
}
}
}
@@ -106,7 +106,7 @@ func scheduleScenarios() []scheduleScenario {
},
},
{
name: "渠道倍率区间按渠道平价覆盖后的 base 折算", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
name: "渠道倍率区间按渠道平价覆盖后的 base 折算(区间自定义标签不用于档位)", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
channel: sonnetChannel(
PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
@@ -114,8 +114,8 @@ func scheduleScenarios() []scheduleScenario {
),
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
requireTier(t, s.Tiers[0], 0, intPtr(200000), "", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 200000, nil, "long", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6))
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6))
},
},
{
@@ -139,9 +139,9 @@ func scheduleScenarios() []scheduleScenario {
),
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 3)
requireTier(t, s.Tiers[0], 0, intPtr(100000), "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 100000, intPtr(200000), "", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[2], 200000, nil, "", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[0], 0, intPtr(100000), "≤100K", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 100000, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[2], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
},
},
{
@@ -153,8 +153,8 @@ func scheduleScenarios() []scheduleScenario {
),
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
requireTier(t, s.Tiers[0], 0, intPtr(200000), "", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 200000, nil, "", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
},
},
{
@@ -166,8 +166,8 @@ func scheduleScenarios() []scheduleScenario {
),
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 3)
requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[2], 1000000, nil, "", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "≤1M", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
requireTier(t, s.Tiers[2], 1000000, nil, ">1M", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
},
},
{
@@ -336,6 +336,13 @@ func TestResolveContextPricingSchedule_Scenarios(t *testing.T) {
for i := 1; i < len(sched.Tiers); i++ {
require.NotNil(t, sched.Tiers[i-1].MaxTokens)
require.Equal(t, *sched.Tiers[i-1].MaxTokens, sched.Tiers[i].MinTokens, "档位连续")
require.Less(t, sched.Tiers[i-1].MinTokens, sched.Tiers[i].MinTokens, "档位按上下文升序")
}
if len(sched.Tiers) > 1 {
for i, tier := range sched.Tiers {
require.NotEmpty(t, tier.Label, "多档时每档都有标签 #%d", i)
}
require.Nil(t, sched.Tiers[len(sched.Tiers)-1].MaxTokens, "末档无上限")
}
if sc.check != nil {
sc.check(t, sched)
@@ -381,14 +381,19 @@ function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boole
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
}
/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return m.pricing?.intervals ?? []
return sortByContext(m.pricing?.intervals ?? [])
}
/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
function officialIntervals(m: PlazaModel): UserPricingInterval[] {
return m.official_pricing?.intervals ?? []
return sortByContext(m.official_pricing?.intervals ?? [])
}
/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
@@ -409,18 +414,14 @@ function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 100–200K / 200K–1M)。 */
/**
* 档位标签:优先后端/管理员给出的 tier_label,否则按区间生成统一形态——
* 有上限为「≤上限」,末档为「>下限」;档位升序排列,相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
*/
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
if (max == null) return `>${formatTokenCount(min)}`
if (min === 0) return `≤${formatTokenCount(max)}`
const lo = formatTokenCount(min)
const hi = formatTokenCount(max)
// 同单位时省略前一个单位(100–200K),节省列宽
const unit = hi.slice(-1)
if (/[KM]/.test(unit) && lo.endsWith(unit)) return `${lo.slice(0, -1)}–${hi}`
return `${lo}–${hi}`
return max == null ? `>${formatTokenCount(min)}` : `≤${formatTokenCount(max)}`
}
function formatTokenCount(n: number): string {
@@ -507,23 +507,21 @@ describe('PlazaModelPricingTable 长上下文阶梯', () => {
expect(marginal.findAll('tbody td')[0].text()).toContain('modelPlaza.table.marginalBadge')
})
it('自定义中间档标签同单位时省略前一个单位', () => {
it('无标签的多档按区间生成统一形态(≤上限 / >下限),并按下限升序展示', () => {
const model = ladderModel({
pricing: {
...ladderModel().pricing!,
// 故意乱序:展示必须按上下文从低到高
intervals: [
{ ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
{ ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' },
{ ...ladderIntervals()[0], min_tokens: 100000, max_tokens: 200000, tier_label: '' },
{ ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' },
{ ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' }
{ ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
{ ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' }
]
}
})
const text = mountTable([model], 1).findAll('tbody td')[1].text()
expect(text).toContain('≤100K')
expect(text).toContain('100–200K')
expect(text).toContain('200K–1M')
expect(text).toContain('>1M')
const rows = mountTable([model], 1).findAll('tbody td')[1].findAll('.leading-5')
expect(rows.map((r) => r.text().split(/\s+/)[0])).toEqual(['≤100K', '≤200K', '≤1M', '>1M'])
})
it('官方无 intervals 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {