profit_preview.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package service
  2. // 分组利润控制离线预演:使用生产只读导出的分组配置、账号倍率、探测状态、
  3. // 用户覆盖倍率与主力模型清单,复用线上 U/D/epsilon 语义推演准入。
  4. // 探测状态仅用于解释账号倍率来源是否健康,不影响准入结论。
  5. import (
  6. "math"
  7. "sort"
  8. "time"
  9. )
  10. const (
  11. ProfitPreviewRateSourceManual = "manual"
  12. ProfitPreviewRateSourceUpstreamProbe = "upstream_probe_sync"
  13. ProfitPreviewWarningProbeMissing = "probe_snapshot_missing"
  14. ProfitPreviewWarningProbeStale = "probe_snapshot_stale"
  15. ProfitPreviewWarningProbeFailed = "probe_failed"
  16. ProfitPreviewWarningProbeUnsupported = "probe_unsupported"
  17. ProfitPreviewWarningManualRateOne = "manual_rate_1_suspected_unmaintained"
  18. )
  19. // ProfitPreviewGroupInput 是一个分组的预演输入。
  20. type ProfitPreviewGroupInput struct {
  21. Group *Group
  22. // Accounts 为该分组绑定且状态可调度的账号,允许 Anthropic/Gemini 分组中
  23. // 参与 mixed scheduling 的 Antigravity 账号。
  24. Accounts []*Account
  25. // UserOverrides 为该分组所有用户覆盖倍率(user_id → 倍率)。
  26. UserOverrides map[int64]float64
  27. // Models 为该分组近期主力模型清单。
  28. Models []string
  29. // AssumeEnabled 在分组当前关闭时仍按其保存配置启用利润门,供部署前执行
  30. // “先关组、预演、逐组重开”。
  31. AssumeEnabled bool
  32. }
  33. // 预演账号分类与线上否决原因一一对应。
  34. const (
  35. ProfitPreviewClassAdmitted = "admitted"
  36. ProfitPreviewClassRejectedThreshold = "rejected_threshold"
  37. ProfitPreviewClassRejectedInvalidRate = "rejected_invalid_account_rate"
  38. )
  39. // ProfitPreviewAccountVerdict 是单账号的预演结论。
  40. type ProfitPreviewAccountVerdict struct {
  41. AccountID int64 `json:"account_id"`
  42. Name string `json:"name"`
  43. Platform string `json:"platform"`
  44. Class string `json:"class"`
  45. AccountRate *float64 `json:"account_rate,omitempty"`
  46. RateSource string `json:"rate_source"`
  47. Warnings []string `json:"warnings,omitempty"`
  48. // RejectedUnderMinD 表示账号在最低有效 D(用户覆盖最坏口径)下会被排除。
  49. RejectedUnderMinD bool `json:"rejected_under_min_d,omitempty"`
  50. // SupportedModels 为该账号支持的主力模型子集。
  51. SupportedModels []string `json:"supported_models,omitempty"`
  52. }
  53. // ProfitPreviewGroupReport 是单分组的预演报告。
  54. type ProfitPreviewGroupReport struct {
  55. GroupID int64 `json:"group_id"`
  56. GroupName string `json:"group_name"`
  57. Platform string `json:"platform"`
  58. EffectiveGate bool `json:"effective_gate"`
  59. AssumedEnabled bool `json:"assumed_enabled,omitempty"`
  60. // DefaultD / MinEffectiveD:分组默认 D 与最低有效 D(含 evalAt 高峰因子;
  61. // MinEffectiveD = min(分组默认, 全部非空用户覆盖) × 高峰因子)。
  62. DefaultD float64 `json:"default_d"`
  63. MinEffectiveD float64 `json:"min_effective_d"`
  64. // ThresholdDefault / ThresholdMinD:两档 D 对应的准入阈值。
  65. ThresholdDefault float64 `json:"threshold_default"`
  66. ThresholdMinD float64 `json:"threshold_min_d"`
  67. // RemainingByModel 仅表示利润门准入账号数,不模拟健康、冷却、限流或槽位。
  68. RemainingByModel map[string]int `json:"profit_admitted_by_model"`
  69. RemainingByModelMinD map[string]int `json:"profit_admitted_by_model_min_d"`
  70. Verdicts []ProfitPreviewAccountVerdict `json:"verdicts"`
  71. }
  72. // PreviewProfitAdmission 对五大平台分组推演利润门准入结果。未启用的分组仅在
  73. // AssumeEnabled=true 时按保存配置执行门;Composite 分组始终不直接安装利润门。
  74. func PreviewProfitAdmission(inputs []ProfitPreviewGroupInput, evalAt time.Time) []ProfitPreviewGroupReport {
  75. reports := make([]ProfitPreviewGroupReport, 0, len(inputs))
  76. for _, in := range inputs {
  77. if in.Group == nil {
  78. continue
  79. }
  80. group := in.Group
  81. effectiveGate := (group.ProfitControlEnabled || in.AssumeEnabled) && profitControlPlatformSupported(group.Platform)
  82. report := ProfitPreviewGroupReport{
  83. GroupID: group.ID,
  84. GroupName: group.Name,
  85. Platform: group.Platform,
  86. EffectiveGate: effectiveGate,
  87. AssumedEnabled: in.AssumeEnabled && !group.ProfitControlEnabled && effectiveGate,
  88. RemainingByModel: make(map[string]int, len(in.Models)),
  89. RemainingByModelMinD: make(map[string]int, len(in.Models)),
  90. }
  91. for _, model := range in.Models {
  92. report.RemainingByModel[model] = 0
  93. report.RemainingByModelMinD[model] = 0
  94. }
  95. peak := group.PeakMultiplierAt(evalAt)
  96. defaultD := group.RateMultiplier * peak
  97. minRate := group.RateMultiplier
  98. for _, override := range in.UserOverrides {
  99. if math.IsNaN(override) || math.IsInf(override, 0) || override < 0 {
  100. continue
  101. }
  102. if override < minRate {
  103. minRate = override
  104. }
  105. }
  106. minD := minRate * peak
  107. deduction := group.ProfitMinMargin + group.ProfitSafetyBuffer
  108. thresholdDefault := clampProfitControlThreshold(defaultD * (1 - deduction))
  109. thresholdMinD := clampProfitControlThreshold(minD * (1 - deduction))
  110. report.DefaultD = defaultD
  111. report.MinEffectiveD = minD
  112. report.ThresholdDefault = thresholdDefault
  113. report.ThresholdMinD = thresholdMinD
  114. for _, account := range in.Accounts {
  115. if account == nil {
  116. continue
  117. }
  118. verdict := previewAccountProfitAdmission(account, effectiveGate, thresholdDefault, thresholdMinD, evalAt)
  119. admittedDefault := verdict.Class == ProfitPreviewClassAdmitted
  120. admittedMinD := admittedDefault && !verdict.RejectedUnderMinD
  121. for _, model := range in.Models {
  122. if !account.IsModelSupported(model) {
  123. continue
  124. }
  125. verdict.SupportedModels = append(verdict.SupportedModels, model)
  126. if admittedDefault {
  127. report.RemainingByModel[model]++
  128. }
  129. if admittedMinD {
  130. report.RemainingByModelMinD[model]++
  131. }
  132. }
  133. sort.Strings(verdict.SupportedModels)
  134. report.Verdicts = append(report.Verdicts, verdict)
  135. }
  136. sort.Slice(report.Verdicts, func(i, j int) bool { return report.Verdicts[i].AccountID < report.Verdicts[j].AccountID })
  137. reports = append(reports, report)
  138. }
  139. return reports
  140. }
  141. func previewAccountProfitAdmission(
  142. account *Account,
  143. effectiveGate bool,
  144. thresholdDefault float64,
  145. thresholdMinD float64,
  146. evalAt time.Time,
  147. ) ProfitPreviewAccountVerdict {
  148. verdict := ProfitPreviewAccountVerdict{
  149. AccountID: account.ID,
  150. Name: account.Name,
  151. Platform: account.Platform,
  152. RateSource: ProfitPreviewRateSourceManual,
  153. }
  154. if enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool); enabled {
  155. verdict.RateSource = ProfitPreviewRateSourceUpstreamProbe
  156. verdict.Warnings = append(verdict.Warnings, profitPreviewProbeWarnings(account, evalAt)...)
  157. } else if account.RateMultiplier != nil && *account.RateMultiplier == 1 {
  158. verdict.Warnings = append(verdict.Warnings, ProfitPreviewWarningManualRateOne)
  159. }
  160. validRate := account.RateMultiplier != nil &&
  161. !math.IsNaN(*account.RateMultiplier) &&
  162. !math.IsInf(*account.RateMultiplier, 0) &&
  163. *account.RateMultiplier >= 0
  164. if validRate {
  165. rate := *account.RateMultiplier
  166. verdict.AccountRate = &rate
  167. }
  168. switch {
  169. case !effectiveGate:
  170. verdict.Class = ProfitPreviewClassAdmitted
  171. case !validRate:
  172. verdict.Class = ProfitPreviewClassRejectedInvalidRate
  173. case profitControlOverThreshold(*account.RateMultiplier, thresholdDefault):
  174. verdict.Class = ProfitPreviewClassRejectedThreshold
  175. default:
  176. verdict.Class = ProfitPreviewClassAdmitted
  177. verdict.RejectedUnderMinD = profitControlOverThreshold(*account.RateMultiplier, thresholdMinD)
  178. }
  179. return verdict
  180. }
  181. func profitPreviewProbeWarnings(account *Account, evalAt time.Time) []string {
  182. snapshot := decodeUpstreamBillingProbeSnapshot(account.Extra)
  183. if snapshot == nil {
  184. return []string{ProfitPreviewWarningProbeMissing}
  185. }
  186. switch snapshot.Status {
  187. case UpstreamBillingProbeStatusFailed:
  188. return []string{ProfitPreviewWarningProbeFailed}
  189. case UpstreamBillingProbeStatusUnsupported:
  190. return []string{ProfitPreviewWarningProbeUnsupported}
  191. case UpstreamBillingProbeStatusOK:
  192. if snapshot.FreshUntil == nil || !evalAt.Before(*snapshot.FreshUntil) {
  193. return []string{ProfitPreviewWarningProbeStale}
  194. }
  195. }
  196. return nil
  197. }