openai_profit_control_pricing_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package service
  2. // 请求级定价与利润门回归:请求级 pricingAt 定价上下文、门复用(failover 阈值稳定)、
  3. // Responses 文本能力利润门、U 使用账号倍率且与探测新鲜度解耦、
  4. // 用量记录定价时刻取值。
  5. import (
  6. "context"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // WithOpenAIRequestPricingContext:装门 + 固定 pricingAt;显式抑制标记
  14. // (媒体/count_tokens/live 等门范围外路径)跳门且防御性装门无法把门加回来。
  15. func TestProfitControl_RequestPricingContext(t *testing.T) {
  16. svc := &OpenAIGatewayService{}
  17. groupID := int64(61)
  18. now := time.Now()
  19. expensive := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  20. profitControlTestAccountWithRate(expensive, 0.8)
  21. t.Run("installs gate and pricing instant", func(t *testing.T) {
  22. base := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0))
  23. ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID)
  24. require.False(t, pricingAt.IsZero())
  25. require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx))
  26. vetoed, reason := OpenAIProfitControlVeto(ctx, expensive)
  27. require.True(t, vetoed)
  28. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  29. })
  30. t.Run("suppress marker skips gate everywhere", func(t *testing.T) {
  31. base := WithOpenAIProfitControlSuppressed(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)))
  32. ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID)
  33. require.False(t, pricingAt.IsZero(), "跳门时 pricingAt 仍需固定供计费共用")
  34. vetoed, _ := OpenAIProfitControlVeto(ctx, expensive)
  35. require.False(t, vetoed)
  36. // service 层防御性装门也必须被抑制标记挡住。
  37. reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID)
  38. vetoed, _ = OpenAIProfitControlVeto(reCtx, expensive)
  39. require.False(t, vetoed)
  40. })
  41. }
  42. // failover 重入复用同一门:请求中途分组配置变化不得改变本请求阈值。
  43. func TestProfitControl_GateReuseKeepsThresholdAcrossFailover(t *testing.T) {
  44. svc := &OpenAIGatewayService{}
  45. groupID := int64(62)
  46. group := profitControlTestGroup(groupID, 0.5, 0)
  47. ctx := svc.withOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)
  48. gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  49. require.True(t, ok)
  50. require.InDelta(t, 0.5, gate.threshold, 1e-12)
  51. // 模拟请求进行中管理员改配置(ctx 分组为同一指针,与 auth 快照语义一致)。
  52. group.ProfitMinMargin = 0.9
  53. reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID)
  54. reGate, ok := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  55. require.True(t, ok)
  56. require.Same(t, gate, reGate, "failover 重入必须复用同一门,阈值不得中途变化")
  57. // 换分组(composite/模型路由成员调度)重新解析;成员分组无门时必须清除
  58. // 父分组门,阈值不得跨组泄漏。
  59. otherID := int64(63)
  60. otherCtx := svc.withOpenAIProfitControlGate(reCtx, &otherID)
  61. otherGate, _ := otherCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  62. require.Nil(t, otherGate, "成员分组未启用利润控制时父分组门必须清除")
  63. now := time.Now()
  64. expensive := upstreamCostTestAccount(8, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute)
  65. vetoed, _ := openAIProfitControlVetoReason(otherCtx, expensive)
  66. require.False(t, vetoed)
  67. }
  68. // D 固定在 pricingAt:高峰因子按请求开始时刻计算,与"当前时刻"无关。
  69. func TestProfitControl_PricingAtFixesDownstreamPeakFactor(t *testing.T) {
  70. svc := &OpenAIGatewayService{}
  71. groupID := int64(64)
  72. group := profitControlTestGroup(groupID, 0, 0)
  73. group.SubscriptionType = SubscriptionTypeSubscription
  74. group.PeakRateEnabled = true
  75. group.PeakRateMultiplier = 3.0
  76. pricingAt := time.Date(2026, time.January, 15, 8, 30, 0, 0, timezone.Location())
  77. outsideWindow := time.Date(2026, time.January, 15, 10, 30, 0, 0, timezone.Location())
  78. group.PeakStart = "08:00"
  79. group.PeakEnd = "09:00"
  80. require.Equal(t, 1.0, group.PeakMultiplierAt(outsideWindow), "构造前提:对照时刻不在窗口内")
  81. require.Equal(t, 3.0, group.PeakMultiplierAt(pricingAt), "构造前提:pricingAt 在窗口内")
  82. ctx := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, pricingAt)
  83. gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID)
  84. require.NotNil(t, gate)
  85. require.InDelta(t, 3.0, gate.threshold, 1e-9, "阈值必须用 pricingAt 时刻的高峰因子(1.0×3.0×(1-0))")
  86. require.Equal(t, pricingAt, gate.pricingAt)
  87. }
  88. // U 只取账号倍率:探测快照内容和新鲜度不再直接参与利润判断。
  89. func TestProfitControl_UsesAccountRateInsteadOfProbeSnapshot(t *testing.T) {
  90. gate := &openAIProfitControlGate{threshold: 0.5, pricingAt: time.Now().Add(-12 * time.Hour)}
  91. ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate)
  92. account := upstreamCostTestAccount(9, UpstreamBillingProbeStatusOK, 0.1, time.Now().Add(-3*time.Hour), 30*time.Minute)
  93. profitControlTestAccountWithRate(account, 0.8)
  94. vetoed, reason := openAIProfitControlVetoReason(ctx, account)
  95. require.True(t, vetoed)
  96. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  97. }
  98. // Responses 是端点能力,不代表媒体请求;原生远程压缩同样要求该能力,
  99. // 因此唯一文本调度入口必须照常安装利润门。
  100. func TestProfitControl_ResponsesCapabilityUsesTextGateAtScheduler(t *testing.T) {
  101. now := time.Now()
  102. expensive := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  103. expensive.Status = StatusActive
  104. expensive.Schedulable = true
  105. expensive.Concurrency = 2
  106. svc := &OpenAIGatewayService{
  107. accountRepo: stubOpenAIAccountRepo{accounts: []Account{*expensive}},
  108. cfg: &config.Config{},
  109. rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
  110. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  111. }
  112. groupID := int64(77)
  113. ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0))
  114. _, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true)
  115. require.ErrorIs(t, err, ErrNoAvailableAccounts, "文本能力必须过利润门")
  116. selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, false, false, true)
  117. require.ErrorIs(t, err, ErrNoAvailableAccounts, "Responses 文本能力不得绕过利润门")
  118. require.Nil(t, selection)
  119. }
  120. // 账号倍率缺失一律视为非法保守拒绝;手工或同步维护了倍率的任意账号类型都按
  121. // 同一阈值判断(OAuth 与 API Key 无差别)。
  122. func TestProfitControl_AccountRateSemantics(t *testing.T) {
  123. now := time.Now()
  124. missing := upstreamCostTestOAuthAccount(2)
  125. manualOAuth := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(3), 0.3)
  126. expensive := profitControlTestAccountWithRate(upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.1, now.Add(-3*time.Hour), 30*time.Minute), 0.8)
  127. group := profitControlTestGroup(77, 0.5, 0)
  128. group.RateMultiplier = 1
  129. base := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, now)
  130. gate := (&OpenAIGatewayService{}).resolveOpenAIProfitControlGate(base, &group.ID)
  131. require.NotNil(t, gate)
  132. gateCtx := context.WithValue(base, openAIProfitControlGateCtxKey{}, gate)
  133. vetoed, reason := openAIProfitControlVetoReason(gateCtx, missing)
  134. require.True(t, vetoed, "缺失账号倍率必须保守拒绝")
  135. require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason)
  136. vetoed, _ = openAIProfitControlVetoReason(gateCtx, manualOAuth)
  137. require.False(t, vetoed, "手工维护的 OAuth 倍率应正常准入")
  138. vetoed, reason = openAIProfitControlVetoReason(gateCtx, expensive)
  139. require.True(t, vetoed)
  140. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  141. }
  142. // 用量记录定价时刻:优先请求级 PricingAt,未装配回退记录时刻。
  143. func TestOpenAIUsagePricingAt(t *testing.T) {
  144. fixed := time.Now().Add(-2 * time.Hour)
  145. require.Equal(t, fixed, openAIUsagePricingAt(&OpenAIRecordUsageInput{PricingAt: fixed}))
  146. fallback := openAIUsagePricingAt(&OpenAIRecordUsageInput{})
  147. require.WithinDuration(t, timezone.Now(), fallback, 5*time.Second)
  148. require.WithinDuration(t, timezone.Now(), openAIUsagePricingAt(nil), 5*time.Second)
  149. }
  150. func TestOpenAIProfitControlStickyBindingOccursOnlyAfterTerminalAdmission(t *testing.T) {
  151. groupID := int64(81)
  152. expensiveID := int64(901)
  153. cheapID := int64(902)
  154. const sessionHash = "profit-sticky"
  155. const cacheKey = "openai:" + sessionHash
  156. cache := &schedulerTestGatewayCache{
  157. sessionBindings: map[string]int64{cacheKey: expensiveID},
  158. }
  159. svc := &OpenAIGatewayService{cache: cache}
  160. ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{
  161. groupID: groupID,
  162. platform: PlatformOpenAI,
  163. threshold: 0.5,
  164. })
  165. require.NoError(t, svc.bindOpenAIStickySessionDuringSelection(ctx, &groupID, sessionHash, cheapID))
  166. require.Equal(t, expensiveID, cache.sessionBindings[cacheKey], "选号阶段不得覆盖原粘性绑定")
  167. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(ctx, &groupID, sessionHash, cheapID))
  168. require.Equal(t, expensiveID, cache.sessionBindings[cacheKey], "终检通过的 fallback 账号不得覆盖原粘性绑定")
  169. cache.sessionBindings[cacheKey] = 0
  170. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(ctx, &groupID, sessionHash, cheapID))
  171. require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无既有绑定时应在终检通过后建立粘性")
  172. }
  173. // WithOpenAITurnPricingContext:长连接 turn 边界重新冻结 pricingAt 并按当前
  174. // 配置重装门(区别于请求级同门复用);已装门时以门所属调度分组为准。
  175. func TestProfitControl_TurnPricingContext(t *testing.T) {
  176. svc := &OpenAIGatewayService{}
  177. groupID := int64(63)
  178. expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.8, time.Now().Add(-time.Minute), 30*time.Minute)
  179. profitControlTestAccountWithRate(expensive, 0.8)
  180. t.Run("refreshes instant and re-resolves gate config", func(t *testing.T) {
  181. group := profitControlTestGroup(groupID, 0.5, 0)
  182. base := profitControlTestCtx(group)
  183. connCtx, connAt := svc.WithOpenAIRequestPricingContext(base, &groupID)
  184. vetoed, _ := OpenAIProfitControlVeto(connCtx, expensive)
  185. require.True(t, vetoed)
  186. // 连接中途运营者放宽 margin:turn 级重装必须生效(请求级复用不生效)。
  187. group.ProfitMinMargin = 0.1
  188. turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &groupID)
  189. require.False(t, turnAt.Before(connAt))
  190. require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx))
  191. vetoed, _ = OpenAIProfitControlVeto(turnCtx, expensive)
  192. require.False(t, vetoed, "turn 级重装应采用最新分组配置")
  193. })
  194. t.Run("keeps scheduled group of the existing gate", func(t *testing.T) {
  195. scheduledGroupID := int64(64)
  196. scheduled := profitControlTestGroup(scheduledGroupID, 0.5, 0)
  197. connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(scheduled), &scheduledGroupID)
  198. // 入口分组与调度分组不同(composite 成员分组场景):turn 重装取门的分组。
  199. entryGroupID := int64(65)
  200. turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID)
  201. gate, ok := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  202. require.True(t, ok)
  203. require.NotNil(t, gate)
  204. require.Equal(t, scheduledGroupID, gate.groupID)
  205. })
  206. t.Run("suppress marker only refreshes instant", func(t *testing.T) {
  207. base := WithOpenAIProfitControlSuppressed(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)))
  208. turnCtx, turnAt := svc.WithOpenAITurnPricingContext(base, &groupID)
  209. require.False(t, turnAt.IsZero())
  210. vetoed, _ := OpenAIProfitControlVeto(turnCtx, expensive)
  211. require.False(t, vetoed)
  212. })
  213. t.Run("clears gate when group disables profit control mid-connection", func(t *testing.T) {
  214. group := profitControlTestGroup(groupID, 0.5, 0)
  215. connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID)
  216. group.ProfitControlEnabled = false
  217. turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &groupID)
  218. vetoed, _ := OpenAIProfitControlVeto(turnCtx, expensive)
  219. require.False(t, vetoed, "关门后 turn 级复核应放行")
  220. })
  221. }
  222. // 无门时准入后绑定回退官方 eager 语义:等待/抢槽路径不得因利润控制关闭而
  223. // 失去粘性绑定(评审 M-Bind 回归锚点)。
  224. func TestOpenAIProfitControlAfterAdmissionBindEagerWithoutGate(t *testing.T) {
  225. groupID := int64(82)
  226. expensiveID := int64(903)
  227. cheapID := int64(904)
  228. const sessionHash = "no-gate-sticky"
  229. const cacheKey = "openai:" + sessionHash
  230. cache := &schedulerTestGatewayCache{
  231. sessionBindings: map[string]int64{cacheKey: expensiveID},
  232. }
  233. svc := &OpenAIGatewayService{cache: cache}
  234. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(context.Background(), &groupID, sessionHash, cheapID))
  235. require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无门时保持既有 eager 绑定行为")
  236. }