gateway_profit_control_v2_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func gatewayProfitTestGroup(id int64, platform string) *Group {
  12. return &Group{
  13. ID: id,
  14. Name: "profit-" + platform,
  15. Platform: platform,
  16. Status: StatusActive,
  17. Hydrated: true,
  18. RateMultiplier: 0.5,
  19. SubscriptionType: SubscriptionTypeStandard,
  20. ProfitControlEnabled: true,
  21. ProfitMinMargin: 0,
  22. ProfitSafetyBuffer: 0,
  23. }
  24. }
  25. func gatewayProfitTestContext(group *Group) context.Context {
  26. ctx := context.WithValue(context.Background(), ctxkey.Group, group)
  27. ctx, _ = WithGatewayTokenRequestPricing(ctx)
  28. return ctx
  29. }
  30. func gatewayProfitTestAccount(id int64, platform string, rate float64, groupID int64) Account {
  31. return Account{
  32. ID: id,
  33. Name: "account",
  34. Platform: platform,
  35. Type: AccountTypeAPIKey,
  36. Status: StatusActive,
  37. Schedulable: true,
  38. Concurrency: 2,
  39. Priority: 1,
  40. RateMultiplier: &rate,
  41. AccountGroups: []AccountGroup{{AccountID: id, GroupID: groupID}},
  42. GroupIDs: []int64{groupID},
  43. }
  44. }
  45. func TestGatewayProfitControlInstallsForFivePlatformsOnlyOnTokenRequests(t *testing.T) {
  46. for _, platform := range []string{
  47. PlatformOpenAI,
  48. PlatformAnthropic,
  49. PlatformGemini,
  50. PlatformGrok,
  51. PlatformAntigravity,
  52. } {
  53. t.Run(platform, func(t *testing.T) {
  54. group := gatewayProfitTestGroup(101, platform)
  55. groupID := group.ID
  56. svc := &GatewayService{}
  57. tokenCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &groupID)
  58. gate, _ := tokenCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  59. require.NotNil(t, gate)
  60. require.Equal(t, platform, gate.platform)
  61. require.InDelta(t, 0.5, gate.threshold, 1e-12)
  62. metadataCtx := context.WithValue(context.Background(), ctxkey.Group, group)
  63. metadataCtx = svc.withGatewayProfitControlGate(metadataCtx, &groupID)
  64. gate, _ = metadataCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  65. require.Nil(t, gate, "未显式标记为 token 请求的入口不得装门")
  66. })
  67. }
  68. }
  69. func TestGatewayProfitControlCompositeBillingUsesScheduledMemberConfig(t *testing.T) {
  70. billingGroup := &Group{
  71. ID: 201,
  72. Platform: PlatformComposite,
  73. Status: StatusActive,
  74. Hydrated: true,
  75. RateMultiplier: 0.4,
  76. SubscriptionType: SubscriptionTypeStandard,
  77. }
  78. memberGroup := gatewayProfitTestGroup(202, PlatformAnthropic)
  79. memberGroup.RateMultiplier = 99
  80. memberGroup.ProfitMinMargin = 0.25
  81. ctx := context.WithValue(context.Background(), ctxkey.Group, billingGroup)
  82. ctx, pricingAt := WithGatewayTokenRequestPricing(ctx)
  83. svc := &GatewayService{
  84. schedulerSnapshot: NewSchedulerSnapshotService(
  85. nil,
  86. nil,
  87. nil,
  88. profitControlGroupRepo{group: memberGroup},
  89. nil,
  90. ),
  91. }
  92. ctx = svc.withGatewayProfitControlGate(ctx, &memberGroup.ID)
  93. gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  94. require.NotNil(t, gate)
  95. require.Equal(t, memberGroup.ID, gate.groupID)
  96. require.Equal(t, PlatformAnthropic, gate.platform)
  97. require.Equal(t, pricingAt, gate.pricingAt)
  98. require.InDelta(t, 0.4*(1-0.25), gate.threshold, 1e-12, "D 必须取 composite 计费父分组,margin 取被调度成员分组")
  99. }
  100. func TestGatewayProfitControlGroupLoadFailureClearsForeignGate(t *testing.T) {
  101. billingGroup := &Group{
  102. ID: 211,
  103. Platform: PlatformComposite,
  104. Status: StatusActive,
  105. Hydrated: true,
  106. RateMultiplier: 0.4,
  107. SubscriptionType: SubscriptionTypeStandard,
  108. }
  109. targetGroupID := int64(212)
  110. ctx := gatewayProfitTestContext(billingGroup)
  111. ctx = context.WithValue(ctx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{
  112. groupID: 210,
  113. platform: PlatformAnthropic,
  114. threshold: 0.1,
  115. })
  116. svc := &GatewayService{
  117. schedulerSnapshot: NewSchedulerSnapshotService(
  118. nil,
  119. nil,
  120. nil,
  121. profitControlFailingGroupRepo{},
  122. nil,
  123. ),
  124. }
  125. ctx = svc.withGatewayProfitControlGate(ctx, &targetGroupID)
  126. gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate)
  127. require.True(t, ok)
  128. require.Nil(t, gate, "加载新分组失败时必须清除其他分组遗留的门")
  129. account := gatewayProfitTestAccount(213, PlatformAnthropic, 0.8, targetGroupID)
  130. require.True(t, svc.isGatewayAccountProfitEligible(ctx, &account), "配置读取失败按既定语义 fail-open")
  131. }
  132. type profitControlFailingGroupRepo struct {
  133. GroupRepository
  134. }
  135. func (profitControlFailingGroupRepo) GetByIDLite(context.Context, int64) (*Group, error) {
  136. return nil, errors.New("group cache unavailable")
  137. }
  138. // 见 profitControlGroupRepo.GetByID:利润门必须走不带账号计数聚合的 lite 读取。
  139. func (profitControlFailingGroupRepo) GetByID(context.Context, int64) (*Group, error) {
  140. panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)")
  141. }
  142. func TestGatewayProfitControlLegacyMixedAndRoutedSelection(t *testing.T) {
  143. t.Run("legacy single-platform selection", func(t *testing.T) {
  144. group := gatewayProfitTestGroup(111, PlatformGrok)
  145. cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID)
  146. expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID)
  147. repo := &mockAccountRepoForPlatform{
  148. accounts: []Account{expensive, cheap},
  149. accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive},
  150. }
  151. svc := &GatewayService{
  152. accountRepo: repo,
  153. cache: &mockGatewayCacheForPlatform{},
  154. cfg: testConfig(),
  155. }
  156. selected, err := svc.SelectAccountForModelWithExclusions(
  157. gatewayProfitTestContext(group), &group.ID, "", "", nil,
  158. )
  159. require.NoError(t, err)
  160. require.Equal(t, cheap.ID, selected.ID)
  161. _, err = svc.SelectAccountForModelWithExclusions(
  162. gatewayProfitTestContext(group), &group.ID, "", "", map[int64]struct{}{cheap.ID: {}},
  163. )
  164. require.Error(t, err)
  165. require.ErrorIs(t, err, ErrNoAvailableAccounts)
  166. })
  167. t.Run("mixed routing filters the routed account", func(t *testing.T) {
  168. group := gatewayProfitTestGroup(112, PlatformAnthropic)
  169. group.ModelRoutingEnabled = true
  170. group.ModelRouting = map[string][]int64{"claude-test": {2, 1}}
  171. cheap := gatewayProfitTestAccount(1, PlatformAntigravity, 0.2, group.ID)
  172. cheap.Extra = map[string]any{"mixed_scheduling": true}
  173. cheap.Credentials = map[string]any{"model_mapping": map[string]any{"claude-test": "claude-test"}}
  174. expensive := gatewayProfitTestAccount(2, PlatformAnthropic, 0.8, group.ID)
  175. repo := &mockAccountRepoForPlatform{
  176. accounts: []Account{expensive, cheap},
  177. accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive},
  178. }
  179. svc := &GatewayService{
  180. accountRepo: repo,
  181. cache: &mockGatewayCacheForPlatform{},
  182. cfg: testConfig(),
  183. }
  184. selected, err := svc.SelectAccountForModelWithExclusions(
  185. gatewayProfitTestContext(group), &group.ID, "", "claude-test", nil,
  186. )
  187. require.NoError(t, err)
  188. require.Equal(t, cheap.ID, selected.ID)
  189. })
  190. }
  191. func TestGatewayProfitControlLoadAwareSelectionAndFailover(t *testing.T) {
  192. group := gatewayProfitTestGroup(121, PlatformGrok)
  193. cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID)
  194. expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID)
  195. repo := &mockAccountRepoForPlatform{
  196. accounts: []Account{expensive, cheap},
  197. accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive},
  198. }
  199. cfg := &config.Config{RunMode: config.RunModeStandard}
  200. cfg.Gateway.Scheduling.LoadBatchEnabled = true
  201. svc := &GatewayService{
  202. accountRepo: repo,
  203. cache: &mockGatewayCacheForPlatform{},
  204. cfg: cfg,
  205. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  206. }
  207. result, err := svc.SelectAccountWithLoadAwareness(
  208. gatewayProfitTestContext(group), &group.ID, "", "", nil, "", 0,
  209. )
  210. require.NoError(t, err)
  211. require.NotNil(t, result)
  212. require.Equal(t, cheap.ID, result.Account.ID)
  213. if result.ReleaseFunc != nil {
  214. result.ReleaseFunc()
  215. }
  216. result, err = svc.SelectAccountWithLoadAwareness(
  217. gatewayProfitTestContext(group),
  218. &group.ID,
  219. "",
  220. "",
  221. map[int64]struct{}{cheap.ID: {}},
  222. "",
  223. 0,
  224. )
  225. require.Nil(t, result)
  226. require.Error(t, err)
  227. require.ErrorIs(t, err, ErrNoAvailableAccounts)
  228. }
  229. func TestGatewayProfitControlStickyVetoKeepsBindingUntilRateRecovers(t *testing.T) {
  230. group := gatewayProfitTestGroup(131, PlatformAnthropic)
  231. expensive := gatewayProfitTestAccount(1, PlatformAnthropic, 0.8, group.ID)
  232. cheap := gatewayProfitTestAccount(2, PlatformAnthropic, 0.2, group.ID)
  233. repo := &mockAccountRepoForPlatform{
  234. accounts: []Account{expensive, cheap},
  235. accountsByID: map[int64]*Account{expensive.ID: &expensive, cheap.ID: &cheap},
  236. }
  237. cache := &mockGatewayCacheForPlatform{
  238. sessionBindings: map[string]int64{"sticky-profit": expensive.ID},
  239. }
  240. svc := &GatewayService{
  241. accountRepo: repo,
  242. cache: cache,
  243. cfg: testConfig(),
  244. }
  245. ctx := gatewayProfitTestContext(group)
  246. selected, err := svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil)
  247. require.NoError(t, err)
  248. require.Equal(t, cheap.ID, selected.ID)
  249. require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "候选过滤不得覆盖旧粘性绑定")
  250. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(
  251. svc.withGatewayProfitControlGate(ctx, &group.ID),
  252. &group.ID,
  253. "sticky-profit",
  254. cheap.ID,
  255. ))
  256. require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "终检通过的 fallback 账号也不得覆盖旧绑定")
  257. require.Zero(t, cache.deletedSessions["sticky-profit"])
  258. recovered := expensive
  259. recoveredRate := 0.2
  260. recovered.RateMultiplier = &recoveredRate
  261. repo.accounts[0] = recovered
  262. repo.accountsByID[recovered.ID] = &repo.accounts[0]
  263. selected, err = svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil)
  264. require.NoError(t, err)
  265. require.Equal(t, recovered.ID, selected.ID, "倍率恢复后应重新命中原粘性账号")
  266. require.Zero(t, cache.deletedSessions["sticky-profit"])
  267. }
  268. type gatewayProfitSnapshotCache struct {
  269. SchedulerCache
  270. account *Account
  271. err error
  272. }
  273. func (c *gatewayProfitSnapshotCache) GetAccount(context.Context, int64) (*Account, error) {
  274. return c.account, c.err
  275. }
  276. type gatewayProfitAccountRepo struct {
  277. AccountRepository
  278. account *Account
  279. err error
  280. }
  281. func (r gatewayProfitAccountRepo) GetByID(context.Context, int64) (*Account, error) {
  282. return r.account, r.err
  283. }
  284. func TestGatewayProfitControlTerminalRefreshUsesReplacementObject(t *testing.T) {
  285. selected := gatewayProfitTestAccount(141, PlatformGemini, 0.2, 1)
  286. replacement := selected
  287. expensiveRate := 0.8
  288. replacement.RateMultiplier = &expensiveRate
  289. snapshot := NewSchedulerSnapshotService(
  290. &gatewayProfitSnapshotCache{account: &replacement},
  291. nil,
  292. gatewayProfitAccountRepo{},
  293. nil,
  294. nil,
  295. )
  296. ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{
  297. groupID: 1,
  298. platform: PlatformGemini,
  299. threshold: 0.5,
  300. })
  301. latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot)
  302. require.Same(t, &replacement, latest)
  303. require.True(t, vetoed)
  304. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  305. require.InDelta(t, 0.2, *selected.RateMultiplier, 1e-12, "测试必须替换缓存对象,不能原地修改旧指针")
  306. }
  307. func TestGatewayProfitControlTerminalRefreshFallsBackFromCacheToDatabase(t *testing.T) {
  308. selected := gatewayProfitTestAccount(145, PlatformAnthropic, 0.2, 1)
  309. replacement := selected
  310. expensiveRate := 0.8
  311. replacement.RateMultiplier = &expensiveRate
  312. snapshot := NewSchedulerSnapshotService(
  313. &gatewayProfitSnapshotCache{err: errors.New("cache unavailable")},
  314. nil,
  315. gatewayProfitAccountRepo{account: &replacement},
  316. nil,
  317. nil,
  318. )
  319. ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{
  320. groupID: 1,
  321. platform: PlatformAnthropic,
  322. threshold: 0.5,
  323. })
  324. latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot)
  325. require.Same(t, &replacement, latest)
  326. require.True(t, vetoed, "缓存读取失败时必须继续从数据库重读,不能直接使用选号旧对象")
  327. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  328. }
  329. func TestGatewayProfitControlTerminalRefreshFailureFallsBackToSelectedObject(t *testing.T) {
  330. selected := gatewayProfitTestAccount(151, PlatformAntigravity, 0.2, 1)
  331. snapshot := NewSchedulerSnapshotService(
  332. &gatewayProfitSnapshotCache{err: errors.New("cache unavailable")},
  333. nil,
  334. gatewayProfitAccountRepo{err: errors.New("database unavailable")},
  335. nil,
  336. nil,
  337. )
  338. ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{
  339. groupID: 1,
  340. platform: PlatformAntigravity,
  341. threshold: 0.5,
  342. })
  343. latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot)
  344. require.Same(t, &selected, latest)
  345. require.False(t, vetoed)
  346. require.Empty(t, reason)
  347. }
  348. // 选号结果携带门:门安装在调度栈局部 ctx 上,handler 必须经
  349. // ContextWithSelectionProfitGate 重放后终检与准入后绑定才可见(评审修复回归)。
  350. func TestGatewayProfitControlSelectionCarriesGateToHandlerContext(t *testing.T) {
  351. group := gatewayProfitTestGroup(1, PlatformAnthropic)
  352. svc := &GatewayService{}
  353. expensive := gatewayProfitTestAccount(161, PlatformAnthropic, 0.9, group.ID)
  354. gateCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &group.ID)
  355. selection, err := svc.newSelectionResult(gateCtx, &expensive, true, nil, nil)
  356. require.NoError(t, err)
  357. require.True(t, selection.ProfitGateActive(), "选号结果必须携带调度栈内生效的门")
  358. // 修复前的缺陷形态:handler 原始 ctx 不含门,终检退化为空操作。
  359. _, vetoed, _ := svc.GatewayProfitControlVetoLatest(context.Background(), &expensive)
  360. require.False(t, vetoed, "对照组:不重放门时终检确实看不到门")
  361. handlerCtx := ContextWithSelectionProfitGate(context.Background(), selection)
  362. latest, vetoed, reason := svc.GatewayProfitControlVetoLatest(handlerCtx, &expensive)
  363. require.True(t, vetoed, "重放门后终检必须真实生效")
  364. require.Equal(t, openAIProfitFilterReasonThreshold, reason)
  365. require.NotNil(t, latest)
  366. // 无门选号不携带门,重放为无操作。
  367. plain, err := svc.newSelectionResult(context.Background(), &expensive, true, nil, nil)
  368. require.NoError(t, err)
  369. require.False(t, plain.ProfitGateActive())
  370. require.Equal(t, context.Background(), ContextWithSelectionProfitGate(context.Background(), plain))
  371. }
  372. // 生图意图不关门(H1/H2 回归锚点):/v1/responses 混合请求即使带生图声明,
  373. // token 定价上下文照常装配,共享门照常安装并否决越线账号。
  374. func TestGatewayProfitControlImageIntentDoesNotDisableGate(t *testing.T) {
  375. group := gatewayProfitTestGroup(2, PlatformAnthropic)
  376. svc := &GatewayService{}
  377. expensive := gatewayProfitTestAccount(162, PlatformAnthropic, 0.9, group.ID)
  378. ctx := gatewayProfitTestContext(group)
  379. ctx = WithOpenAIImageGenerationIntent(ctx)
  380. gateCtx := svc.withGatewayProfitControlGate(ctx, &group.ID)
  381. require.False(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive),
  382. "请求体里的生图声明(含被动 image_gen namespace)不得关闭利润门")
  383. }
  384. // 无门时准入后绑定回退官方 eager 语义;门下读失败保守不写(评审 M-Bind 回归)。
  385. func TestGatewayProfitControlAfterAdmissionBindSemantics(t *testing.T) {
  386. groupID := int64(3)
  387. expensiveID := int64(171)
  388. cheapID := int64(172)
  389. t.Run("eager without gate", func(t *testing.T) {
  390. cache := &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{"s": expensiveID}}
  391. svc := &GatewayService{cache: cache}
  392. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(context.Background(), &groupID, "s", cheapID))
  393. require.Equal(t, cheapID, cache.sessionBindings["s"], "无门时保持既有 eager 绑定行为")
  394. })
  395. t.Run("gated read failure is conservative", func(t *testing.T) {
  396. // mock 的 miss 返回非 sentinel 错误,等价于 Redis 读失败:门下保守不写。
  397. cache := &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{}}
  398. svc := &GatewayService{cache: cache}
  399. gate := &openAIProfitControlGate{groupID: groupID, platform: PlatformAnthropic, threshold: 0.5}
  400. gateCtx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate)
  401. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(gateCtx, &groupID, "absent", cheapID))
  402. require.NotContains(t, cache.sessionBindings, "absent")
  403. })
  404. t.Run("gated sentinel miss binds", func(t *testing.T) {
  405. cache := &sentinelMissGatewayCache{mockGatewayCacheForPlatform: &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{}}}
  406. svc := &GatewayService{cache: cache}
  407. gate := &openAIProfitControlGate{groupID: groupID, platform: PlatformAnthropic, threshold: 0.5}
  408. gateCtx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate)
  409. require.NoError(t, svc.BindStickySessionAfterProfitAdmission(gateCtx, &groupID, "fresh", cheapID))
  410. require.Equal(t, cheapID, cache.sessionBindings["fresh"], "门下无既有绑定(sentinel miss)应建立粘性")
  411. })
  412. }
  413. // sentinelMissGatewayCache 让 miss 返回与真实仓库一致的 ErrStickySessionNotFound。
  414. type sentinelMissGatewayCache struct {
  415. *mockGatewayCacheForPlatform
  416. }
  417. func (c *sentinelMissGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
  418. if id, ok := c.sessionBindings[sessionHash]; ok {
  419. return id, nil
  420. }
  421. return 0, ErrStickySessionNotFound
  422. }