billing_context_schedule.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "math"
  6. "sort"
  7. "strconv"
  8. "time"
  9. )
  10. // ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
  11. // Gemini 边际口径("marginal")已随平台旧规则一并移除。
  12. type ContextPricingBasis string
  13. // ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
  14. const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
  15. // ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
  16. // nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
  17. type ContextPricingTier struct {
  18. MinTokens int
  19. MaxTokens *int
  20. Label string
  21. Input *float64
  22. Output *float64
  23. CacheWrite *float64
  24. CacheWrite1h *float64
  25. CacheRead *float64
  26. }
  27. // TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。
  28. type TimePricingPeriod struct {
  29. StartTime string
  30. EndTime string
  31. Multiplier float64
  32. }
  33. // TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。
  34. // WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
  35. type TimePricingSchedule struct {
  36. Timezone string
  37. WeekdaysOnly bool
  38. Periods []TimePricingPeriod
  39. }
  40. // ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。
  41. // 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。
  42. // Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。
  43. type ContextPricingSchedule struct {
  44. Basis ContextPricingBasis
  45. Tiers []ContextPricingTier
  46. TimePricing *TimePricingSchedule
  47. }
  48. // ContextPricingScheduleInput 阶梯表查询输入。
  49. type ContextPricingScheduleInput struct {
  50. Model string
  51. // Group 为 nil 表示查官方参考价:无分组、无渠道定价,也不套用平台旧规则。
  52. Group *Group
  53. // Platform 为请求的具体平台(composite 分组传模型所属平台),
  54. // 决定渠道定价查找与平台旧规则的适用。
  55. Platform string
  56. }
  57. var errContextPricingResolverRequired = errors.New("context pricing schedule: resolver is required")
  58. // 探针步长:相邻两个探针点相差 contextProbeDelta 个 token,单价 = Δcost / Δtoken。
  59. const contextProbeDelta = 1000
  60. // ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
  61. //
  62. // 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
  63. // CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
  64. // 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
  65. // 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
  66. // 规则变更无需同步到这里;相邻同价段会合并。
  67. //
  68. // 非 token 计费模式返回 (nil, nil);模型无任何定价来源时返回 ErrModelPricingUnavailable。
  69. func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, resolver *ModelPricingResolver, in ContextPricingScheduleInput) (*ContextPricingSchedule, error) {
  70. if s == nil || resolver == nil {
  71. return nil, errContextPricingResolverRequired
  72. }
  73. if ctx == nil {
  74. ctx = context.Background()
  75. }
  76. if in.Platform != "" {
  77. ctx = WithResolvedTargetPlatform(ctx, in.Platform)
  78. }
  79. pricingInput := PricingInput{Model: in.Model, Group: in.Group}
  80. if in.Group != nil {
  81. gid := in.Group.ID
  82. pricingInput.GroupID = &gid
  83. }
  84. resolved := resolver.Resolve(ctx, pricingInput)
  85. if resolved == nil {
  86. return nil, ErrModelPricingUnavailable
  87. }
  88. if resolved.Mode != "" && resolved.Mode != BillingModeToken {
  89. return nil, nil
  90. }
  91. req := TokenCostRequest{
  92. Ctx: ctx,
  93. Model: in.Model,
  94. Group: in.Group,
  95. RateMultiplier: 1,
  96. Resolver: resolver,
  97. Resolved: resolved,
  98. }
  99. probe := func(tokens UsageTokens) (*CostBreakdown, error) {
  100. r := req
  101. r.Tokens = tokens
  102. return s.CalculateTokenCostForRequest(r)
  103. }
  104. plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
  105. segments := buildContextSegments(plan.bounds)
  106. tiers := make([]ContextPricingTier, 0, len(segments))
  107. for _, seg := range segments {
  108. tier, err := probeContextTier(seg, resolved, probe)
  109. if err != nil {
  110. return nil, err
  111. }
  112. tiers = append(tiers, tier)
  113. }
  114. tiers = mergeEqualContextTiers(tiers)
  115. applyContextTierLabels(tiers, plan)
  116. return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
  117. }
  118. // resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
  119. // 时段来自解析到的渠道定价配置,每个时段的倍率用计费自己的 resolvedChannelTimeMultiplier
  120. // 在时段内取值:定价来源不是渠道(分组价卡覆盖)、配置非法等情况下计费按 1 计,
  121. // 这里也就自然得到"无分时"。倍率为 1 的时段不列出。
  122. func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule {
  123. if resolved == nil || resolved.channelPricing == nil || resolved.channelPricing.TimePricing == nil {
  124. return nil
  125. }
  126. cfg := resolved.channelPricing.TimePricing
  127. location, err := loadChannelTimePricingLocation(cfg.Timezone)
  128. if err != nil {
  129. return nil
  130. }
  131. type probedPeriod struct {
  132. start int
  133. period TimePricingPeriod
  134. }
  135. probed := make([]probedPeriod, 0, len(cfg.Periods))
  136. for _, period := range cfg.Periods {
  137. start, err := parseChannelTime(period.StartTime, false)
  138. if err != nil {
  139. continue
  140. }
  141. // 时段按每日循环,取该时段开始后 1 秒作为探针时刻。
  142. // 锚点日必须是工作日(2026-01-05 为周一):weekdays_only 配置在周末恒为 1,
  143. // 锚点落在周末会把时段整组剔除。
  144. at := time.Date(2026, time.January, 5, 0, 0, start+1, 0, location)
  145. multiplier := resolvedChannelTimeMultiplier(resolved, at)
  146. if multiplier == 1 {
  147. continue
  148. }
  149. probed = append(probed, probedPeriod{start: start, period: TimePricingPeriod{
  150. StartTime: period.StartTime,
  151. EndTime: period.EndTime,
  152. Multiplier: multiplier,
  153. }})
  154. }
  155. if len(probed) == 0 {
  156. return nil
  157. }
  158. sort.SliceStable(probed, func(i, j int) bool { return probed[i].start < probed[j].start })
  159. out := &TimePricingSchedule{
  160. Timezone: cfg.Timezone,
  161. WeekdaysOnly: cfg.WeekdaysOnly,
  162. Periods: make([]TimePricingPeriod, 0, len(probed)),
  163. }
  164. for _, p := range probed {
  165. out.Periods = append(out.Periods, p.period)
  166. }
  167. return out
  168. }
  169. // contextBreakpointPlan 描述断点来源。
  170. type contextBreakpointPlan struct {
  171. bounds []int
  172. // thresholdBound 为目录阶梯/旧规则的断点值((0,b] / (b,∞)),0 表示无。
  173. thresholdBound int
  174. // thresholdInclusive 为真表示达到阈值即进入高档(断点 = 阈值-1)。
  175. thresholdInclusive bool
  176. threshold int
  177. }
  178. // contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
  179. func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
  180. plan := contextBreakpointPlan{}
  181. if !resolved.longContextPricingEnabled {
  182. return plan
  183. }
  184. if len(resolved.Intervals) > 0 {
  185. // 区间边界即断点;空洞段(含末档上限之外)由计费回落 base,探针会自然得到基础价。
  186. set := make(map[int]struct{}, len(resolved.Intervals)*2)
  187. for i := range resolved.Intervals {
  188. iv := &resolved.Intervals[i]
  189. if iv.MinTokens > 0 {
  190. set[iv.MinTokens] = struct{}{}
  191. }
  192. if iv.MaxTokens != nil {
  193. set[*iv.MaxTokens] = struct{}{}
  194. }
  195. }
  196. for b := range set {
  197. plan.bounds = append(plan.bounds, b)
  198. }
  199. sort.Ints(plan.bounds)
  200. return plan
  201. }
  202. pricing := resolver.GetIntervalPricing(resolved, 1)
  203. if pricing == nil {
  204. return plan
  205. }
  206. pricing = s.applyModelSpecificPricingPolicy(model, pricing)
  207. if pricing.LongContextInputThreshold <= 0 {
  208. return plan
  209. }
  210. bound := pricing.LongContextInputThreshold
  211. if pricing.LongContextThresholdInclusive {
  212. bound--
  213. }
  214. if bound <= 0 {
  215. return plan
  216. }
  217. plan.bounds = []int{bound}
  218. plan.thresholdBound = bound
  219. plan.threshold = pricing.LongContextInputThreshold
  220. plan.thresholdInclusive = pricing.LongContextThresholdInclusive
  221. return plan
  222. }
  223. // contextSegment 探针用的 (min, max] 段;max 为 nil 表示无上限。
  224. type contextSegment struct {
  225. min int
  226. max *int
  227. }
  228. // buildContextSegments 把升序断点切成 (0,b1], (b1,b2], …, (bn,∞);无断点时为单个开区间。
  229. func buildContextSegments(bounds []int) []contextSegment {
  230. segments := make([]contextSegment, 0, len(bounds)+1)
  231. prev := 0
  232. for _, b := range bounds {
  233. if b <= prev {
  234. continue
  235. }
  236. upper := b
  237. segments = append(segments, contextSegment{min: prev, max: &upper})
  238. prev = b
  239. }
  240. segments = append(segments, contextSegment{min: prev})
  241. return segments
  242. }
  243. // probeContextTier 在段内两点探针,单价 = ΔActualCost / Δtoken(倍率固定为 1)。
  244. // 每次只喂一种 token,ActualCost 即该项费用;旧边际规则的加倍只体现在 ActualCost
  245. // 而不在分项费用里,因此统一读 ActualCost。整单阶梯与边际规则在同一段内都是
  246. // 线性函数,差商同时适用,无需区分规则类型。
  247. func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(UsageTokens) (*CostBreakdown, error)) (ContextPricingTier, error) {
  248. tier := ContextPricingTier{MinTokens: seg.min, MaxTokens: seg.max}
  249. c := seg.min + 1
  250. delta := contextProbeDelta
  251. if seg.max != nil {
  252. if width := *seg.max - seg.min; width-1 < delta {
  253. delta = width - 1
  254. }
  255. }
  256. var err error
  257. tier.Input, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: n} }, c, delta, probe)
  258. if err != nil {
  259. return tier, err
  260. }
  261. tier.CacheRead, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheReadTokens: n} }, c, delta, probe)
  262. if err != nil {
  263. return tier, err
  264. }
  265. tier.CacheWrite, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n} }, c, delta, probe)
  266. if err != nil {
  267. return tier, err
  268. }
  269. // 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。
  270. tier.CacheWrite1h, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n, CacheCreation1hTokens: n} }, c, delta, probe)
  271. if err != nil {
  272. return tier, err
  273. }
  274. tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe)
  275. if err != nil {
  276. return tier, err
  277. }
  278. explicit := explicitContextPricingFields(resolved, c)
  279. tier.Input = contextPricePtr(tier.Input, explicit.input)
  280. tier.Output = contextPricePtr(tier.Output, explicit.output)
  281. tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite)
  282. tier.CacheWrite1h = contextPricePtr(tier.CacheWrite1h, explicit.cacheWrite1h)
  283. tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead)
  284. return tier, nil
  285. }
  286. // probeComponentPrice 返回 [from, from+delta] 上 ActualCost 的差商;delta 为 0(退化段)时退回平均单价。
  287. func probeComponentPrice(tokensAt func(int) UsageTokens, from, delta int, probe func(UsageTokens) (*CostBreakdown, error)) (*float64, error) {
  288. if delta <= 0 {
  289. n := from
  290. if n <= 0 {
  291. n = 1
  292. }
  293. cost, err := probe(tokensAt(n))
  294. if err != nil {
  295. return nil, err
  296. }
  297. v := roundContextPrice(cost.ActualCost / float64(n))
  298. return &v, nil
  299. }
  300. lo, err := probe(tokensAt(from))
  301. if err != nil {
  302. return nil, err
  303. }
  304. hi, err := probe(tokensAt(from + delta))
  305. if err != nil {
  306. return nil, err
  307. }
  308. v := roundContextPrice((hi.ActualCost - lo.ActualCost) / float64(delta))
  309. return &v, nil
  310. }
  311. // roundContextPrice 去掉差商带来的浮点噪声(保留 12 位有效数字)。
  312. func roundContextPrice(v float64) float64 {
  313. if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) {
  314. return 0
  315. }
  316. r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 12, 64), 64)
  317. if err != nil {
  318. return v
  319. }
  320. return r
  321. }
  322. type explicitContextFields struct {
  323. input, output, cacheWrite, cacheWrite1h, cacheRead bool
  324. }
  325. // explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。
  326. // 显式配置为 0 时计费按 $0 收,展示应为 $0 而非“无价”。
  327. func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) explicitContextFields {
  328. var out explicitContextFields
  329. if resolved == nil || resolved.channelPricing == nil {
  330. return out
  331. }
  332. cp := resolved.channelPricing
  333. out.input = cp.InputPrice != nil
  334. out.output = cp.OutputPrice != nil
  335. out.cacheWrite = cp.CacheWritePrice != nil
  336. out.cacheWrite1h = cp.CacheWrite1hPrice != nil || cp.CacheWritePrice != nil
  337. out.cacheRead = cp.CacheReadPrice != nil
  338. if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil {
  339. out.input = out.input || iv.InputPrice != nil
  340. out.output = out.output || iv.OutputPrice != nil
  341. out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil
  342. out.cacheWrite1h = out.cacheWrite1h || iv.CacheWrite1hPrice != nil || iv.CacheWritePrice != nil
  343. out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil
  344. }
  345. return out
  346. }
  347. func contextPricePtr(v *float64, explicit bool) *float64 {
  348. if v == nil {
  349. return nil
  350. }
  351. if *v == 0 && !explicit {
  352. return nil
  353. }
  354. return v
  355. }
  356. // mergeEqualContextTiers 合并相邻且四项单价相同的段(倍率 ≤1 的目录、关闭阶梯等场景塌成单档)。
  357. func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier {
  358. if len(tiers) < 2 {
  359. return tiers
  360. }
  361. merged := make([]ContextPricingTier, 0, len(tiers))
  362. for _, t := range tiers {
  363. if n := len(merged); n > 0 && sameContextPrices(merged[n-1], t) {
  364. merged[n-1].MaxTokens = t.MaxTokens
  365. continue
  366. }
  367. merged = append(merged, t)
  368. }
  369. return merged
  370. }
  371. func sameContextPrices(a, b ContextPricingTier) bool {
  372. return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) &&
  373. samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheWrite1h, b.CacheWrite1h) && samePricePtr(a.CacheRead, b.CacheRead)
  374. }
  375. func samePricePtr(a, b *float64) bool {
  376. if a == nil || b == nil {
  377. return a == nil && b == nil
  378. }
  379. if *a == *b {
  380. return true
  381. }
  382. scale := math.Max(math.Abs(*a), math.Abs(*b))
  383. return math.Abs(*a-*b) <= scale*1e-9
  384. }
  385. // applyContextTierLabels 给多档阶梯打统一形态的标签:有上限的档为「≤上限」,
  386. // 末档为「>下限」;档位按上下文升序,因此相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
  387. // 目录阶梯/旧规则在"达到阈值即进高档"时改用 < / ≥ 表达阈值本身。
  388. // 渠道区间上的 tier_label 不用于 token 档位(token 模式的管理表单不暴露该字段)。
  389. func applyContextTierLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) {
  390. if len(tiers) < 2 {
  391. return
  392. }
  393. for i := range tiers {
  394. t := &tiers[i]
  395. switch {
  396. case plan.thresholdInclusive && t.MaxTokens != nil && *t.MaxTokens == plan.thresholdBound:
  397. t.Label = "<" + formatContextTokenCount(plan.threshold)
  398. case plan.thresholdInclusive && t.MinTokens == plan.thresholdBound:
  399. t.Label = "≥" + formatContextTokenCount(plan.threshold)
  400. case t.MaxTokens != nil:
  401. t.Label = "≤" + formatContextTokenCount(*t.MaxTokens)
  402. default:
  403. t.Label = ">" + formatContextTokenCount(t.MinTokens)
  404. }
  405. }
  406. }
  407. // formatContextTokenCount 把 token 数格式化为 272K / 1M 等短标签。
  408. func formatContextTokenCount(n int) string {
  409. switch {
  410. case n >= 1_000_000 && n%1_000_000 == 0:
  411. return strconv.Itoa(n/1_000_000) + "M"
  412. case n >= 1_000_000:
  413. return trimFloatLabel(float64(n)/1_000_000) + "M"
  414. case n >= 1_000:
  415. return trimFloatLabel(float64(n)/1_000) + "K"
  416. }
  417. return strconv.Itoa(n)
  418. }
  419. func trimFloatLabel(v float64) string {
  420. return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
  421. }