openai_profit_slot_recheck_test.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. //go:build unit
  2. package handler
  3. // 槽位终检与生图跳门回归(handler 半程):
  4. // - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选,
  5. // 不写响应、不绑定粘连;
  6. // - openAIResponsesRequiredCapability 的请求能力映射覆盖生图与原生远程压缩。
  7. import (
  8. "context"
  9. "net/http/httptest"
  10. "sync/atomic"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. "go.uber.org/zap"
  18. )
  19. type profitCountingConcurrencyCache struct {
  20. fakeConcurrencyCache
  21. accountReleases atomic.Int64
  22. }
  23. func (c *profitCountingConcurrencyCache) ReleaseAccountSlot(context.Context, int64, string) error {
  24. c.accountReleases.Add(1)
  25. return nil
  26. }
  27. func profitSlotTestAccount(id int64, rate float64) *service.Account {
  28. now := time.Now()
  29. return &service.Account{
  30. ID: id,
  31. Platform: service.PlatformOpenAI,
  32. Type: service.AccountTypeAPIKey,
  33. Status: service.StatusActive,
  34. Schedulable: true,
  35. Concurrency: 2,
  36. RateMultiplier: &rate,
  37. Extra: map[string]any{
  38. "upstream_billing_probe": map[string]any{
  39. "status": service.UpstreamBillingProbeStatusOK,
  40. "received_at": now.Add(-time.Minute),
  41. "fresh_until": now.Add(30 * time.Minute),
  42. "data": map[string]any{
  43. "billing_scope": "token",
  44. "resolved_rate_multiplier": rate,
  45. "peak_rate_enabled": false,
  46. },
  47. },
  48. },
  49. }
  50. }
  51. func profitSlotTestContext(t *testing.T, gw *service.OpenAIGatewayService, groupID int64, suppress bool) context.Context {
  52. t.Helper()
  53. group := &service.Group{
  54. ID: groupID,
  55. Platform: service.PlatformOpenAI,
  56. Status: service.StatusActive,
  57. Hydrated: true,
  58. RateMultiplier: 1.0,
  59. SubscriptionType: service.SubscriptionTypeStandard,
  60. ProfitControlEnabled: true,
  61. ProfitMinMargin: 0.5,
  62. }
  63. base := context.WithValue(context.Background(), ctxkey.Group, group)
  64. if suppress {
  65. base = service.WithOpenAIProfitControlSuppressed(base)
  66. }
  67. ctx, pricingAt := gw.WithOpenAIRequestPricingContext(base, &groupID)
  68. require.False(t, pricingAt.IsZero())
  69. return ctx
  70. }
  71. func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. gw := &service.OpenAIGatewayService{}
  74. groupID := int64(50)
  75. newHandler := func(cache *profitCountingConcurrencyCache) *OpenAIGatewayHandler {
  76. return &OpenAIGatewayHandler{
  77. gatewayService: gw,
  78. concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatClaude, 0),
  79. }
  80. }
  81. newSelection := func(account *service.Account) *service.AccountSelectionResult {
  82. return &service.AccountSelectionResult{
  83. Account: account,
  84. Acquired: false,
  85. WaitPlan: &service.AccountWaitPlan{AccountID: account.ID, MaxConcurrency: 2, Timeout: time.Second, MaxWaiting: 2},
  86. }
  87. }
  88. t.Run("veto releases slot and requests reschedule without writing response", func(t *testing.T) {
  89. cache := &profitCountingConcurrencyCache{}
  90. h := newHandler(cache)
  91. w := httptest.NewRecorder()
  92. c, _ := gin.CreateTestContext(w)
  93. c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false))
  94. streamStarted := false
  95. release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(1, 0.8)), false, &streamStarted, zap.NewNop())
  96. require.Equal(t, openAISlotAcquireProfitVetoed, result)
  97. require.Nil(t, release)
  98. require.Zero(t, w.Body.Len(), "利润终检否决不得写出任何响应")
  99. require.Equal(t, int64(1), cache.accountReleases.Load(), "否决后必须立即释放已获取的槽位")
  100. })
  101. t.Run("qualifying account acquires normally", func(t *testing.T) {
  102. cache := &profitCountingConcurrencyCache{}
  103. h := newHandler(cache)
  104. w := httptest.NewRecorder()
  105. c, _ := gin.CreateTestContext(w)
  106. c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false))
  107. streamStarted := false
  108. release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(2, 0.3)), false, &streamStarted, zap.NewNop())
  109. require.Equal(t, openAISlotAcquireOK, result)
  110. require.NotNil(t, release)
  111. release()
  112. })
  113. t.Run("image intent suppression keeps official behavior", func(t *testing.T) {
  114. cache := &profitCountingConcurrencyCache{}
  115. h := newHandler(cache)
  116. w := httptest.NewRecorder()
  117. c, _ := gin.CreateTestContext(w)
  118. c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, true))
  119. streamStarted := false
  120. release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(3, 0.8)), false, &streamStarted, zap.NewNop())
  121. require.Equal(t, openAISlotAcquireOK, result, "生图意图跳门:过贵账号照常获取(图片边界不装门)")
  122. require.NotNil(t, release)
  123. release()
  124. })
  125. }
  126. func TestOpenAIResponsesRequiredCapabilityForRequest(t *testing.T) {
  127. require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapability(true, service.PlatformOpenAI))
  128. require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(false, service.PlatformOpenAI))
  129. require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(true, service.PlatformGrok))
  130. require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformOpenAI))
  131. require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformGrok))
  132. }