mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
模型广场:增加渠道分时段计价展示
- 阶梯表查询附带分时倍率时段:时段取自计费解析到的渠道定价, 每个时段的倍率由计费的 resolvedChannelTimeMultiplier 在时段内取值, 分组价卡覆盖或配置非法时自然不出现;倍率为 1 的时段不列 - 广场模型条目新增 time_pricing(时区 + 时段 + 倍率) - 前端把分时时段展开为独立行:模型名旁标注时段,价格按时段倍率折算, 倍率列显示生效倍率;时区与计算口径放在提示中
This commit is contained in:
@@ -46,6 +46,19 @@ type modelPlazaOfficialPricing struct {
|
||||
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。
|
||||
type modelPlazaTimePricingPeriod struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
}
|
||||
|
||||
// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。
|
||||
type modelPlazaTimePricing struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []modelPlazaTimePricingPeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
|
||||
type modelPlazaModel struct {
|
||||
Name string `json:"name"`
|
||||
@@ -54,6 +67,8 @@ type modelPlazaModel struct {
|
||||
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
|
||||
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
|
||||
LongContextBasis string `json:"long_context_basis,omitempty"`
|
||||
// TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。
|
||||
TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
@@ -172,6 +187,7 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
LongContextBasis: string(m.LongContextBasis),
|
||||
TimePricing: toModelPlazaTimePricing(m.TimePricing),
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
@@ -197,6 +213,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
return dto
|
||||
}
|
||||
|
||||
// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。
|
||||
func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing {
|
||||
if p == nil || len(p.Periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods))
|
||||
for _, period := range p.Periods {
|
||||
periods = append(periods, modelPlazaTimePricingPeriod{
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &modelPlazaTimePricing{Timezone: p.Timezone, Periods: periods}
|
||||
}
|
||||
|
||||
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
|
||||
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
|
||||
if p == nil {
|
||||
|
||||
@@ -113,6 +113,8 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty")
|
||||
_, hasBasis := model["long_context_basis"]
|
||||
require.False(t, hasBasis, "单档模型不输出 long_context_basis")
|
||||
_, hasTimePricing := model["time_pricing"]
|
||||
require.False(t, hasTimePricing, "无分时时不输出 time_pricing")
|
||||
|
||||
// 无专属倍率:user_rate_multiplier 整个字段省略
|
||||
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
|
||||
@@ -179,3 +181,30 @@ func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) {
|
||||
}
|
||||
|
||||
func testPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
|
||||
g := service.PlazaGroup{
|
||||
ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "deepseek-chat",
|
||||
Platform: "deepseek",
|
||||
Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)},
|
||||
TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
model := decoded["models"].([]any)[0].(map[string]any)
|
||||
tp := model["time_pricing"].(map[string]any)
|
||||
require.Equal(t, "Asia/Shanghai", tp["timezone"])
|
||||
periods := tp["periods"].([]any)
|
||||
require.Len(t, periods, 1)
|
||||
first := periods[0].(map[string]any)
|
||||
require.Equal(t, "00:30", first["start_time"])
|
||||
require.Equal(t, "08:30", first["end_time"])
|
||||
require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContextPricingBasis 阶梯的计价基准。
|
||||
@@ -30,11 +31,26 @@ type ContextPricingTier struct {
|
||||
CacheRead *float64
|
||||
}
|
||||
|
||||
// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。
|
||||
type TimePricingPeriod struct {
|
||||
StartTime string
|
||||
EndTime string
|
||||
Multiplier float64
|
||||
}
|
||||
|
||||
// TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。
|
||||
type TimePricingSchedule struct {
|
||||
Timezone string
|
||||
Periods []TimePricingPeriod
|
||||
}
|
||||
|
||||
// ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。
|
||||
// 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。
|
||||
// Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。
|
||||
type ContextPricingSchedule struct {
|
||||
Basis ContextPricingBasis
|
||||
Tiers []ContextPricingTier
|
||||
Basis ContextPricingBasis
|
||||
Tiers []ContextPricingTier
|
||||
TimePricing *TimePricingSchedule
|
||||
}
|
||||
|
||||
// ContextPricingScheduleInput 阶梯表查询输入。
|
||||
@@ -126,7 +142,53 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
if legacy != nil {
|
||||
basis = ContextPricingBasisMarginal
|
||||
}
|
||||
return &ContextPricingSchedule{Basis: basis, Tiers: tiers}, nil
|
||||
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||
}
|
||||
|
||||
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
|
||||
// 时段来自解析到的渠道定价配置,每个时段的倍率用计费自己的 resolvedChannelTimeMultiplier
|
||||
// 在时段内取值:定价来源不是渠道(分组价卡覆盖)、配置非法等情况下计费按 1 计,
|
||||
// 这里也就自然得到"无分时"。倍率为 1 的时段不列出。
|
||||
func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule {
|
||||
if resolved == nil || resolved.channelPricing == nil || resolved.channelPricing.TimePricing == nil {
|
||||
return nil
|
||||
}
|
||||
cfg := resolved.channelPricing.TimePricing
|
||||
location, err := loadChannelTimePricingLocation(cfg.Timezone)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
type probedPeriod struct {
|
||||
start int
|
||||
period TimePricingPeriod
|
||||
}
|
||||
probed := make([]probedPeriod, 0, len(cfg.Periods))
|
||||
for _, period := range cfg.Periods {
|
||||
start, err := parseChannelTime(period.StartTime, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 时段按每日循环,取任意一天该时段开始后 1 秒作为探针时刻。
|
||||
at := time.Date(2026, time.January, 1, 0, 0, start+1, 0, location)
|
||||
multiplier := resolvedChannelTimeMultiplier(resolved, at)
|
||||
if multiplier == 1 {
|
||||
continue
|
||||
}
|
||||
probed = append(probed, probedPeriod{start: start, period: TimePricingPeriod{
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
Multiplier: multiplier,
|
||||
}})
|
||||
}
|
||||
if len(probed) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(probed, func(i, j int) bool { return probed[i].start < probed[j].start })
|
||||
out := &TimePricingSchedule{Timezone: cfg.Timezone, Periods: make([]TimePricingPeriod, 0, len(probed))}
|
||||
for _, p := range probed {
|
||||
out.Periods = append(out.Periods, p.period)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contextBreakpointPlan 描述断点来源。
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -491,3 +492,91 @@ func assertCostClose(t *testing.T, want, got float64, format string, args ...any
|
||||
tolerance := 1e-12 + 1e-9*math.Abs(want)
|
||||
require.InDeltaf(t, want, got, tolerance, format, args...)
|
||||
}
|
||||
|
||||
func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing {
|
||||
return []ChannelModelPricing{{
|
||||
Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
|
||||
InputPrice: testPtrFloat64(2e-6), TimePricing: tp,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestResolveContextPricingSchedule_TimePricing(t *testing.T) {
|
||||
valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
|
||||
{StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
{StartTime: "12:00", EndTime: "13:00", Multiplier: 1},
|
||||
}}
|
||||
|
||||
t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
|
||||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sched.TimePricing)
|
||||
require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone)
|
||||
require.Equal(t, []TimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
{StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
|
||||
}, sched.TimePricing.Periods)
|
||||
// 阶梯表单价是标准时段价,不含分时倍率
|
||||
requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input")
|
||||
|
||||
// 对账:时段内的真实计费 = 标准单价 × token × 倍率
|
||||
group := enabledGroup(PlatformAnthropic)
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group})
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
require.NoError(t, err)
|
||||
for _, tc := range []struct {
|
||||
at time.Time
|
||||
want float64
|
||||
}{
|
||||
{time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5},
|
||||
{time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1},
|
||||
{time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2},
|
||||
{time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1},
|
||||
} {
|
||||
cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000},
|
||||
RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) {
|
||||
invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
{StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠
|
||||
}}
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil)
|
||||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sched.TimePricing)
|
||||
})
|
||||
|
||||
t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
|
||||
group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||||
Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6),
|
||||
}}}
|
||||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||||
Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sched.TimePricing)
|
||||
})
|
||||
|
||||
t.Run("无分时配置为 nil", func(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil)
|
||||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sched.TimePricing)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type PlazaModel struct {
|
||||
OfficialPricing *PlazaOfficialPricing
|
||||
// LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。
|
||||
LongContextBasis ContextPricingBasis
|
||||
// TimePricing 计费会生效的分时倍率时段;无分时为 nil。
|
||||
TimePricing *TimePricingSchedule
|
||||
}
|
||||
|
||||
// PlazaGroup 模型广场中以分组为顶层的条目。
|
||||
@@ -233,6 +235,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode
|
||||
if len(sched.Tiers) > 1 {
|
||||
m.LongContextBasis = sched.Basis
|
||||
}
|
||||
m.TimePricing = sched.TimePricing
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,3 +469,27 @@ func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) {
|
||||
require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价")
|
||||
require.Nil(t, m.OfficialPricing)
|
||||
}
|
||||
|
||||
func TestListGroups_TimePricingPassthrough(t *testing.T) {
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelPricing: []ChannelModelPricing{{
|
||||
Platform: PlatformDeepseek, Models: []string{"deepseek-chat"}, BillingMode: BillingModeToken,
|
||||
InputPrice: testPtrFloat64(0.28e-6), OutputPrice: testPtrFloat64(0.42e-6),
|
||||
TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
}},
|
||||
}},
|
||||
}}
|
||||
groups := []Group{{ID: 10, Name: "cn", Platform: PlatformDeepseek, RateMultiplier: 1, LongContextPricingEnabled: true}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformDeepseek}, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
m := out[0].Models[0]
|
||||
require.NotNil(t, m.TimePricing)
|
||||
require.Equal(t, "Asia/Shanghai", m.TimePricing.Timezone)
|
||||
require.Len(t, m.TimePricing.Periods, 1)
|
||||
require.InDelta(t, 0.5, m.TimePricing.Periods[0].Multiplier, 1e-12)
|
||||
// 展示单价为标准时段价
|
||||
require.InDelta(t, 0.28e-6, *m.Pricing.InputPrice, 1e-15)
|
||||
}
|
||||
|
||||
@@ -27,14 +27,30 @@ export interface PlazaOfficialPricing {
|
||||
*/
|
||||
export type PlazaLongContextBasis = 'whole_request' | 'marginal'
|
||||
|
||||
/** 分时倍率时段:配置时区当天 [start_time, end_time) 内整单实付乘 multiplier。 */
|
||||
export interface PlazaTimePricingPeriod {
|
||||
start_time: string
|
||||
end_time: string
|
||||
multiplier: number
|
||||
}
|
||||
|
||||
/** 计费会生效的分时倍率(仅倍率 ≠ 1 的时段,已按开始时间升序)。 */
|
||||
export interface PlazaTimePricing {
|
||||
/** IANA 时区名,如 Asia/Shanghai。 */
|
||||
timezone: string
|
||||
periods: PlazaTimePricingPeriod[]
|
||||
}
|
||||
|
||||
export interface PlazaModel {
|
||||
name: string
|
||||
platform: string
|
||||
/** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算)。 */
|
||||
/** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */
|
||||
pricing: UserSupportedModelPricing | null
|
||||
official_pricing: PlazaOfficialPricing | null
|
||||
/** 仅多档模型返回。 */
|
||||
long_context_basis?: PlazaLongContextBasis
|
||||
/** 仅配置了分时倍率的模型返回。 */
|
||||
time_pricing?: PlazaTimePricing
|
||||
}
|
||||
|
||||
export interface ModelPlazaGroup {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
|
||||
<table class="w-full min-w-[1000px] table-fixed border-collapse text-sm tabular-nums">
|
||||
<colgroup>
|
||||
<col class="w-[20%]" />
|
||||
<col class="w-[25%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[15%]" />
|
||||
<col class="w-[14%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[15%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[8%]" />
|
||||
<col class="w-[14%]" />
|
||||
<col class="w-[8%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr
|
||||
@@ -58,14 +58,22 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in sortedModels"
|
||||
:key="`${m.platform}:${m.name}`"
|
||||
v-for="{ model: m, period, key } in rows"
|
||||
:key="key"
|
||||
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
|
||||
>
|
||||
<!-- 模型名 + 非 token 计费模式徽章 -->
|
||||
<!-- 模型名 + 非 token 计费模式徽章;分时时段行额外标注时段 -->
|
||||
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
|
||||
<!-- 时段徽章紧跟模型名,其余徽章排在后面,空间不足时先换行的是它们 -->
|
||||
<span
|
||||
v-if="period"
|
||||
class="inline-flex items-center whitespace-nowrap rounded-md bg-gray-100 px-1 py-0.5 font-mono text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
|
||||
:title="t('modelPlaza.table.timePricingRowHint', { timezone: m.time_pricing?.timezone })"
|
||||
>
|
||||
{{ formatTimeWindow(period) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="platform && m.platform !== platform"
|
||||
:class="[
|
||||
@@ -101,10 +109,10 @@
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500" :title="tierHint(m)">{{ tierLabel(iv) }}</span>
|
||||
{{ paidPerMillion(iv.input_price) }}
|
||||
{{ paidPerMillion(iv.input_price, period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.input_price, period) }}</template>
|
||||
</td>
|
||||
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
|
||||
<template v-if="tokenIntervals(m).length">
|
||||
@@ -114,10 +122,10 @@
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
:title="tierHint(m)"
|
||||
>
|
||||
{{ paidPerMillion(iv.output_price) }}
|
||||
{{ paidPerMillion(iv.output_price, period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.output_price, period) }}</template>
|
||||
</td>
|
||||
<td class="pz-cell px-3 py-2.5 align-middle">
|
||||
<template v-if="hasTierCachePricing(tokenIntervals(m))">
|
||||
@@ -129,9 +137,9 @@
|
||||
>
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
|
||||
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_write_price) }}
|
||||
{{ paidPerMillion(iv.cache_write_price, period) }}
|
||||
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_read_price) }}
|
||||
{{ paidPerMillion(iv.cache_read_price, period) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
@@ -142,11 +150,11 @@
|
||||
>
|
||||
<div>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
|
||||
{{ paidPerMillion(m.pricing?.cache_write_price) }}
|
||||
{{ paidPerMillion(m.pricing?.cache_write_price, period) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
|
||||
{{ paidPerMillion(m.pricing?.cache_read_price) }}
|
||||
{{ paidPerMillion(m.pricing?.cache_read_price, period) }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
@@ -246,12 +254,18 @@
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</td>
|
||||
|
||||
<!-- 折扣倍率(生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
|
||||
<!-- 折扣倍率(分时时段行展示 生效倍率×时段倍率;生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
|
||||
<td
|
||||
class="border-l border-gray-100 py-2.5 pl-3 pr-5 text-right align-middle font-mono text-xs dark:border-dark-700/60"
|
||||
>
|
||||
<span
|
||||
v-if="usesIndependentImageRate(m)"
|
||||
v-if="period"
|
||||
class="font-bold text-primary-600 dark:text-primary-400"
|
||||
:title="t('modelPlaza.table.timePricingRateHint', { rate: effectiveRate, multiplier: period.multiplier })"
|
||||
>{{ periodRate(period) }}x</span
|
||||
>
|
||||
<span
|
||||
v-else-if="usesIndependentImageRate(m)"
|
||||
class="font-bold text-gray-700 dark:text-gray-300"
|
||||
>{{ requestRate(m) }}x</span
|
||||
>
|
||||
@@ -277,7 +291,7 @@ import {
|
||||
BILLING_MODE_IMAGE,
|
||||
type BillingMode
|
||||
} from '@/constants/channel'
|
||||
import type { PlazaModel } from '@/api/modelPlaza'
|
||||
import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza'
|
||||
import type { UserPricingInterval } from '@/api/channels'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -338,10 +352,35 @@ function billingModeLabel(m: PlazaModel): string {
|
||||
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
|
||||
const MIN_DECIMALS = 2
|
||||
|
||||
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
|
||||
function paidPerMillion(value: number | null | undefined): string {
|
||||
/** 表格行:每个模型一行标准价;配置了分时倍率的模型再按时段各加一行。 */
|
||||
interface PlazaRow {
|
||||
model: PlazaModel
|
||||
period: PlazaTimePricingPeriod | null
|
||||
key: string
|
||||
}
|
||||
|
||||
const rows = computed<PlazaRow[]>(() =>
|
||||
sortedModels.value.flatMap((m) => {
|
||||
const base: PlazaRow = { model: m, period: null, key: `${m.platform}:${m.name}` }
|
||||
const periodRows = timePeriods(m).map<PlazaRow>((p, idx) => ({
|
||||
model: m,
|
||||
period: p,
|
||||
key: `${m.platform}:${m.name}:${idx}`
|
||||
}))
|
||||
return [base, ...periodRows]
|
||||
})
|
||||
)
|
||||
|
||||
/** 时段行的生效倍率 = 生效倍率 × 时段倍率(去掉浮点噪声)。 */
|
||||
function periodRate(period: PlazaTimePricingPeriod): number {
|
||||
return Math.round(effectiveRate.value * period.multiplier * 1000) / 1000
|
||||
}
|
||||
|
||||
/** 实付价 = 渠道单价 × 生效倍率(时段行再乘时段倍率),按 $/1M token 展示。 */
|
||||
function paidPerMillion(value: number | null | undefined, period: PlazaTimePricingPeriod | null = null): string {
|
||||
if (value == null) return '-'
|
||||
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
|
||||
const rate = period ? periodRate(period) : effectiveRate.value
|
||||
return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS)
|
||||
}
|
||||
|
||||
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
|
||||
@@ -381,6 +420,17 @@ 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
|
||||
}
|
||||
|
||||
/** 分时倍率时段(后端只给出倍率 ≠ 1 的时段,已升序)。 */
|
||||
function timePeriods(m: PlazaModel): PlazaTimePricingPeriod[] {
|
||||
return m.time_pricing?.periods ?? []
|
||||
}
|
||||
|
||||
/** “00:30–08:30”;整分钟的 HH:mm:ss 省略秒。 */
|
||||
function formatTimeWindow(p: PlazaTimePricingPeriod): string {
|
||||
const clock = (v: string) => v.replace(/^(\d{2}:\d{2}):00$/, '$1')
|
||||
return `${clock(p.start_time)}–${clock(p.end_time)}`
|
||||
}
|
||||
|
||||
/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
|
||||
function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
|
||||
return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
|
||||
|
||||
@@ -533,3 +533,58 @@ describe('PlazaModelPricingTable 长上下文阶梯', () => {
|
||||
expect(cells[6].text()).toContain('(1h')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlazaModelPricingTable 分时计价', () => {
|
||||
function timePricedModel() {
|
||||
return tokenModel({
|
||||
name: 'deepseek-chat',
|
||||
platform: 'deepseek',
|
||||
time_pricing: {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [
|
||||
{ start_time: '00:30', end_time: '08:30:00', multiplier: 0.5 },
|
||||
{ start_time: '18:00', end_time: '22:00', multiplier: 1.2 }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('有分时倍率的模型展开为标准行 + 每时段一行,时段行价格按倍率折算且倍率列显示生效倍率', () => {
|
||||
const wrapper = mountTable([timePricedModel()], 0.8)
|
||||
const trs = wrapper.findAll('tbody tr')
|
||||
expect(trs).toHaveLength(3)
|
||||
|
||||
// 标准行:输入 3 × 0.8
|
||||
const baseCells = trs[0].findAll('td')
|
||||
expect(baseCells[0].text()).toBe('deepseek-chat')
|
||||
expect(baseCells[1].text()).toContain('$2.40')
|
||||
expect(baseCells[7].text()).toContain('0.8x')
|
||||
|
||||
// 夜间时段行:输入 3 × 0.8 × 0.5,倍率 0.4x,标注时段不含时区
|
||||
const nightCells = trs[1].findAll('td')
|
||||
expect(nightCells[0].text()).toContain('deepseek-chat')
|
||||
expect(nightCells[0].text()).toContain('00:30–08:30')
|
||||
expect(nightCells[0].text()).not.toContain('Asia/Shanghai')
|
||||
// 时区只放在 tooltip 里(i18n mock 不做插值,这里只断言挂了说明)
|
||||
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
|
||||
expect(nightCells[1].text()).toContain('$1.20')
|
||||
expect(nightCells[2].text()).toContain('$6.00')
|
||||
expect(nightCells[3].text()).toContain('$1.50')
|
||||
expect(nightCells[7].text()).toContain('0.4x')
|
||||
|
||||
// 晚高峰行:3 × 0.8 × 1.2 = 2.88,倍率 0.96x
|
||||
const peakCells = trs[2].findAll('td')
|
||||
expect(peakCells[0].text()).toContain('18:00–22:00')
|
||||
expect(peakCells[1].text()).toContain('$2.88')
|
||||
expect(peakCells[7].text()).toContain('0.96x')
|
||||
|
||||
// 官方列不受时段影响
|
||||
expect(nightCells[4].text()).toContain('$3.00')
|
||||
})
|
||||
|
||||
it('无分时倍率时只有一行,不渲染时段标注', () => {
|
||||
const wrapper = mountTable([tokenModel()], 1)
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -604,6 +604,8 @@ export default {
|
||||
tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)',
|
||||
tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected',
|
||||
marginalBadge: 'excess-only tiers',
|
||||
timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row',
|
||||
timePricingRateHint: 'Effective rate {rate} × period multiplier {multiplier}',
|
||||
paidPrice: 'Your Price (Discounted)',
|
||||
officialPrice: 'Official Price',
|
||||
rate: 'Rate',
|
||||
|
||||
@@ -609,6 +609,8 @@ export default {
|
||||
tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价',
|
||||
tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价',
|
||||
marginalBadge: '超出部分计价',
|
||||
timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费',
|
||||
timePricingRateHint: '生效倍率 {rate} × 时段倍率 {multiplier}',
|
||||
paidPrice: '实付价格(折后)',
|
||||
officialPrice: '官方价格',
|
||||
rate: '折扣倍率',
|
||||
|
||||
Reference in New Issue
Block a user