模型广场:上下文档位统一标签形态并保证升序
- 阶梯表标签由计费层统一生成:有上限的档为「≤上限」、末档为「>下限」 (达到阈值即进高档时用 < / ≥),不再沿用渠道区间的自定义 tier_label; 合并同价段只看单价 - 前端档位按下限升序兜底展示,无标签时按同一形态生成
This commit is contained in:
@@ -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 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {
|
||||
|
||||
Reference in New Issue
Block a user