mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
- flash 低谷价更新为 $0.15/$0.60/$0.003 per MTok(2026-09-10 官方降价) - deepseek-v4-pro 自 2026-09-14 04:00 UTC 起按 Flash 价计费(上游路由变更) - 计费时点显式驱动 pro→Flash 切换,定价展示与计费两条路径同源 - deepseek-flash 保留 reasoning_effort=max;补 Codex 模型清单与前端白名单 - 价表 JSON 新增 deepseek-flash 条目并同步 flash 系价格
462 lines
16 KiB
Go
462 lines
16 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||
)
|
||
|
||
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
|
||
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
|
||
type ContextPricingBasis string
|
||
|
||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||
|
||
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
||
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
||
type ContextPricingTier struct {
|
||
MinTokens int
|
||
MaxTokens *int
|
||
Label string
|
||
Input *float64
|
||
Output *float64
|
||
CacheWrite *float64
|
||
CacheWrite1h *float64
|
||
CacheRead *float64
|
||
}
|
||
|
||
// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。
|
||
type TimePricingPeriod struct {
|
||
StartTime string
|
||
EndTime string
|
||
Multiplier float64
|
||
}
|
||
|
||
// TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。
|
||
// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
|
||
type TimePricingSchedule struct {
|
||
Timezone string
|
||
WeekdaysOnly bool
|
||
Periods []TimePricingPeriod
|
||
}
|
||
|
||
// ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。
|
||
// 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。
|
||
// Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。
|
||
type ContextPricingSchedule struct {
|
||
Basis ContextPricingBasis
|
||
Tiers []ContextPricingTier
|
||
TimePricing *TimePricingSchedule
|
||
}
|
||
|
||
// ContextPricingScheduleInput 阶梯表查询输入。
|
||
type ContextPricingScheduleInput struct {
|
||
Model string
|
||
// Group 为 nil 表示查官方参考价:无分组、无渠道定价,也不套用平台旧规则。
|
||
Group *Group
|
||
// Platform 为请求的具体平台(composite 分组传模型所属平台),
|
||
// 决定渠道定价查找与平台旧规则的适用。
|
||
Platform string
|
||
}
|
||
|
||
var errContextPricingResolverRequired = errors.New("context pricing schedule: resolver is required")
|
||
|
||
// 探针步长:相邻两个探针点相差 contextProbeDelta 个 token,单价 = Δcost / Δtoken。
|
||
const contextProbeDelta = 1000
|
||
|
||
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
|
||
//
|
||
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
|
||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
|
||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
|
||
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
|
||
// 规则变更无需同步到这里;相邻同价段会合并。
|
||
//
|
||
// 非 token 计费模式返回 (nil, nil);模型无任何定价来源时返回 ErrModelPricingUnavailable。
|
||
func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, resolver *ModelPricingResolver, in ContextPricingScheduleInput) (*ContextPricingSchedule, error) {
|
||
if s == nil || resolver == nil {
|
||
return nil, errContextPricingResolverRequired
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
if in.Platform != "" {
|
||
ctx = WithResolvedTargetPlatform(ctx, in.Platform)
|
||
}
|
||
|
||
pricingInput := PricingInput{Model: in.Model, Group: in.Group}
|
||
if in.Group != nil {
|
||
gid := in.Group.ID
|
||
pricingInput.GroupID = &gid
|
||
}
|
||
resolved := resolver.Resolve(ctx, pricingInput)
|
||
if resolved == nil {
|
||
return nil, ErrModelPricingUnavailable
|
||
}
|
||
if resolved.Mode != "" && resolved.Mode != BillingModeToken {
|
||
return nil, nil
|
||
}
|
||
|
||
req := TokenCostRequest{
|
||
Ctx: ctx,
|
||
Model: in.Model,
|
||
Group: in.Group,
|
||
RateMultiplier: 1,
|
||
Resolver: resolver,
|
||
Resolved: resolved,
|
||
}
|
||
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
|
||
r := req
|
||
r.Tokens = tokens
|
||
return s.CalculateTokenCostForRequest(r)
|
||
}
|
||
|
||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
|
||
segments := buildContextSegments(plan.bounds)
|
||
|
||
tiers := make([]ContextPricingTier, 0, len(segments))
|
||
for _, seg := range segments {
|
||
tier, err := probeContextTier(seg, resolved, probe)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tiers = append(tiers, tier)
|
||
}
|
||
tiers = mergeEqualContextTiers(tiers)
|
||
applyContextTierLabels(tiers, plan)
|
||
|
||
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, 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 秒作为探针时刻。
|
||
// 锚点日必须是工作日(2026-01-05 为周一):weekdays_only 配置在周末恒为 1,
|
||
// 锚点落在周末会把时段整组剔除。
|
||
at := time.Date(2026, time.January, 5, 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,
|
||
WeekdaysOnly: cfg.WeekdaysOnly,
|
||
Periods: make([]TimePricingPeriod, 0, len(probed)),
|
||
}
|
||
for _, p := range probed {
|
||
out.Periods = append(out.Periods, p.period)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// contextBreakpointPlan 描述断点来源。
|
||
type contextBreakpointPlan struct {
|
||
bounds []int
|
||
// thresholdBound 为目录阶梯/旧规则的断点值((0,b] / (b,∞)),0 表示无。
|
||
thresholdBound int
|
||
// thresholdInclusive 为真表示达到阈值即进入高档(断点 = 阈值-1)。
|
||
thresholdInclusive bool
|
||
threshold int
|
||
}
|
||
|
||
// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
|
||
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
|
||
plan := contextBreakpointPlan{}
|
||
if !resolved.longContextPricingEnabled {
|
||
return plan
|
||
}
|
||
if len(resolved.Intervals) > 0 {
|
||
// 区间边界即断点;空洞段(含末档上限之外)由计费回落 base,探针会自然得到基础价。
|
||
set := make(map[int]struct{}, len(resolved.Intervals)*2)
|
||
for i := range resolved.Intervals {
|
||
iv := &resolved.Intervals[i]
|
||
if iv.MinTokens > 0 {
|
||
set[iv.MinTokens] = struct{}{}
|
||
}
|
||
if iv.MaxTokens != nil {
|
||
set[*iv.MaxTokens] = struct{}{}
|
||
}
|
||
}
|
||
for b := range set {
|
||
plan.bounds = append(plan.bounds, b)
|
||
}
|
||
sort.Ints(plan.bounds)
|
||
return plan
|
||
}
|
||
pricing := resolver.GetIntervalPricing(resolved, 1)
|
||
if pricing == nil {
|
||
return plan
|
||
}
|
||
// 该路径无既有计费时点(ContextPricingScheduleInput 无时间字段),显式传
|
||
// 当前时刻;此处 pricing 仅取 LongContextInputThreshold 等时间无关字段,
|
||
// DeepSeek pro→Flash 切换不影响断点结果。
|
||
pricing = s.applyModelSpecificPricingPolicyEx(model, pricing, true, timezone.Now())
|
||
if pricing.LongContextInputThreshold <= 0 {
|
||
return plan
|
||
}
|
||
bound := pricing.LongContextInputThreshold
|
||
if pricing.LongContextThresholdInclusive {
|
||
bound--
|
||
}
|
||
if bound <= 0 {
|
||
return plan
|
||
}
|
||
plan.bounds = []int{bound}
|
||
plan.thresholdBound = bound
|
||
plan.threshold = pricing.LongContextInputThreshold
|
||
plan.thresholdInclusive = pricing.LongContextThresholdInclusive
|
||
return plan
|
||
}
|
||
|
||
// contextSegment 探针用的 (min, max] 段;max 为 nil 表示无上限。
|
||
type contextSegment struct {
|
||
min int
|
||
max *int
|
||
}
|
||
|
||
// buildContextSegments 把升序断点切成 (0,b1], (b1,b2], …, (bn,∞);无断点时为单个开区间。
|
||
func buildContextSegments(bounds []int) []contextSegment {
|
||
segments := make([]contextSegment, 0, len(bounds)+1)
|
||
prev := 0
|
||
for _, b := range bounds {
|
||
if b <= prev {
|
||
continue
|
||
}
|
||
upper := b
|
||
segments = append(segments, contextSegment{min: prev, max: &upper})
|
||
prev = b
|
||
}
|
||
segments = append(segments, contextSegment{min: prev})
|
||
return segments
|
||
}
|
||
|
||
// probeContextTier 在段内两点探针,单价 = ΔActualCost / Δtoken(倍率固定为 1)。
|
||
// 每次只喂一种 token,ActualCost 即该项费用;旧边际规则的加倍只体现在 ActualCost
|
||
// 而不在分项费用里,因此统一读 ActualCost。整单阶梯与边际规则在同一段内都是
|
||
// 线性函数,差商同时适用,无需区分规则类型。
|
||
func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(UsageTokens) (*CostBreakdown, error)) (ContextPricingTier, error) {
|
||
tier := ContextPricingTier{MinTokens: seg.min, MaxTokens: seg.max}
|
||
c := seg.min + 1
|
||
delta := contextProbeDelta
|
||
if seg.max != nil {
|
||
if width := *seg.max - seg.min; width-1 < delta {
|
||
delta = width - 1
|
||
}
|
||
}
|
||
|
||
var err error
|
||
tier.Input, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: n} }, c, delta, probe)
|
||
if err != nil {
|
||
return tier, err
|
||
}
|
||
tier.CacheRead, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheReadTokens: n} }, c, delta, probe)
|
||
if err != nil {
|
||
return tier, err
|
||
}
|
||
tier.CacheWrite, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n} }, c, delta, probe)
|
||
if err != nil {
|
||
return tier, err
|
||
}
|
||
// 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。
|
||
tier.CacheWrite1h, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n, CacheCreation1hTokens: n} }, c, delta, probe)
|
||
if err != nil {
|
||
return tier, err
|
||
}
|
||
tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe)
|
||
if err != nil {
|
||
return tier, err
|
||
}
|
||
|
||
explicit := explicitContextPricingFields(resolved, c)
|
||
tier.Input = contextPricePtr(tier.Input, explicit.input)
|
||
tier.Output = contextPricePtr(tier.Output, explicit.output)
|
||
tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite)
|
||
tier.CacheWrite1h = contextPricePtr(tier.CacheWrite1h, explicit.cacheWrite1h)
|
||
tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead)
|
||
return tier, nil
|
||
}
|
||
|
||
// probeComponentPrice 返回 [from, from+delta] 上 ActualCost 的差商;delta 为 0(退化段)时退回平均单价。
|
||
func probeComponentPrice(tokensAt func(int) UsageTokens, from, delta int, probe func(UsageTokens) (*CostBreakdown, error)) (*float64, error) {
|
||
if delta <= 0 {
|
||
n := from
|
||
if n <= 0 {
|
||
n = 1
|
||
}
|
||
cost, err := probe(tokensAt(n))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
v := roundContextPrice(cost.ActualCost / float64(n))
|
||
return &v, nil
|
||
}
|
||
lo, err := probe(tokensAt(from))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hi, err := probe(tokensAt(from + delta))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
v := roundContextPrice((hi.ActualCost - lo.ActualCost) / float64(delta))
|
||
return &v, nil
|
||
}
|
||
|
||
// roundContextPrice 去掉差商带来的浮点噪声(保留 12 位有效数字)。
|
||
func roundContextPrice(v float64) float64 {
|
||
if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) {
|
||
return 0
|
||
}
|
||
r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 12, 64), 64)
|
||
if err != nil {
|
||
return v
|
||
}
|
||
return r
|
||
}
|
||
|
||
type explicitContextFields struct {
|
||
input, output, cacheWrite, cacheWrite1h, cacheRead bool
|
||
}
|
||
|
||
// explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。
|
||
// 显式配置为 0 时计费按 $0 收,展示应为 $0 而非“无价”。
|
||
func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) explicitContextFields {
|
||
var out explicitContextFields
|
||
if resolved == nil || resolved.channelPricing == nil {
|
||
return out
|
||
}
|
||
cp := resolved.channelPricing
|
||
out.input = cp.InputPrice != nil
|
||
out.output = cp.OutputPrice != nil
|
||
out.cacheWrite = cp.CacheWritePrice != nil
|
||
out.cacheWrite1h = cp.CacheWrite1hPrice != nil || cp.CacheWritePrice != nil
|
||
out.cacheRead = cp.CacheReadPrice != nil
|
||
if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil {
|
||
out.input = out.input || iv.InputPrice != nil
|
||
out.output = out.output || iv.OutputPrice != nil
|
||
out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil
|
||
out.cacheWrite1h = out.cacheWrite1h || iv.CacheWrite1hPrice != nil || iv.CacheWritePrice != nil
|
||
out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func contextPricePtr(v *float64, explicit bool) *float64 {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
if *v == 0 && !explicit {
|
||
return nil
|
||
}
|
||
return v
|
||
}
|
||
|
||
// 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 && sameContextPrices(merged[n-1], t) {
|
||
merged[n-1].MaxTokens = t.MaxTokens
|
||
continue
|
||
}
|
||
merged = append(merged, t)
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func sameContextPrices(a, b ContextPricingTier) bool {
|
||
return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) &&
|
||
samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheWrite1h, b.CacheWrite1h) && samePricePtr(a.CacheRead, b.CacheRead)
|
||
}
|
||
|
||
func samePricePtr(a, b *float64) bool {
|
||
if a == nil || b == nil {
|
||
return a == nil && b == nil
|
||
}
|
||
if *a == *b {
|
||
return true
|
||
}
|
||
scale := math.Max(math.Abs(*a), math.Abs(*b))
|
||
return math.Abs(*a-*b) <= scale*1e-9
|
||
}
|
||
|
||
// applyContextTierLabels 给多档阶梯打统一形态的标签:有上限的档为「≤上限」,
|
||
// 末档为「>下限」;档位按上下文升序,因此相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
|
||
// 目录阶梯/旧规则在"达到阈值即进高档"时改用 < / ≥ 表达阈值本身。
|
||
// 渠道区间上的 tier_label 不用于 token 档位(token 模式的管理表单不暴露该字段)。
|
||
func applyContextTierLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) {
|
||
if len(tiers) < 2 {
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// formatContextTokenCount 把 token 数格式化为 272K / 1M 等短标签。
|
||
func formatContextTokenCount(n int) string {
|
||
switch {
|
||
case n >= 1_000_000 && n%1_000_000 == 0:
|
||
return strconv.Itoa(n/1_000_000) + "M"
|
||
case n >= 1_000_000:
|
||
return trimFloatLabel(float64(n)/1_000_000) + "M"
|
||
case n >= 1_000:
|
||
return trimFloatLabel(float64(n)/1_000) + "K"
|
||
}
|
||
return strconv.Itoa(n)
|
||
}
|
||
|
||
func trimFloatLabel(v float64) string {
|
||
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
|
||
}
|