diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index ae91543adc..ea4869d9ca 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -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) } } } diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 04b7816c4a..9223c78352 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -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) diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 96ad18df68..66082a0dcc 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -381,14 +381,19 @@ function hasOfficialCache(o: NonNullable): 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 { diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 4ef79cce76..3bc2e54700 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -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 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {