openai_images_failover_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "sync"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. "github.com/tidwall/gjson"
  18. "go.uber.org/zap"
  19. "go.uber.org/zap/zaptest/observer"
  20. )
  21. type openAIImagesFailoverAccountRepo struct {
  22. service.AccountRepository
  23. accounts []service.Account
  24. }
  25. func (r openAIImagesFailoverAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
  26. for i := range r.accounts {
  27. if r.accounts[i].ID == id {
  28. account := r.accounts[i]
  29. return &account, nil
  30. }
  31. }
  32. return nil, service.ErrNoAvailableAccounts
  33. }
  34. func (r openAIImagesFailoverAccountRepo) ListSchedulableByGroupIDAndPlatform(_ context.Context, _ int64, platform string) ([]service.Account, error) {
  35. return r.accountsForPlatform(platform), nil
  36. }
  37. func (r openAIImagesFailoverAccountRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]service.Account, error) {
  38. return r.accountsForPlatform(platform), nil
  39. }
  40. func (r openAIImagesFailoverAccountRepo) ListSchedulableUngroupedByPlatform(_ context.Context, platform string) ([]service.Account, error) {
  41. return r.accountsForPlatform(platform), nil
  42. }
  43. func (r openAIImagesFailoverAccountRepo) accountsForPlatform(platform string) []service.Account {
  44. out := make([]service.Account, 0, len(r.accounts))
  45. for _, account := range r.accounts {
  46. if account.Platform == platform {
  47. out = append(out, account)
  48. }
  49. }
  50. return out
  51. }
  52. type openAIImagesFailoverHTTPUpstream struct {
  53. service.HTTPUpstream
  54. mu sync.Mutex
  55. accountIDs []int64
  56. }
  57. func (u *openAIImagesFailoverHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
  58. u.mu.Lock()
  59. u.accountIDs = append(u.accountIDs, accountID)
  60. u.mu.Unlock()
  61. return &http.Response{
  62. StatusCode: http.StatusOK,
  63. Header: http.Header{
  64. "Content-Type": []string{"text/event-stream"},
  65. "X-Request-Id": []string{"req_img_failover"},
  66. },
  67. Body: io.NopCloser(bytes.NewBufferString(
  68. "data: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_error\",\"message\":\"image backend unavailable\"}}\n\n",
  69. )),
  70. }, nil
  71. }
  72. func (u *openAIImagesFailoverHTTPUpstream) calls() []int64 {
  73. u.mu.Lock()
  74. defer u.mu.Unlock()
  75. return append([]int64(nil), u.accountIDs...)
  76. }
  77. func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhenExhausted(t *testing.T) {
  78. gin.SetMode(gin.TestMode)
  79. groupID := int64(3130)
  80. accounts := []service.Account{
  81. {
  82. ID: 1,
  83. Name: "image-account-1",
  84. Platform: service.PlatformOpenAI,
  85. Type: service.AccountTypeOAuth,
  86. Status: service.StatusActive,
  87. Schedulable: true,
  88. Concurrency: 0,
  89. Priority: 0,
  90. Credentials: map[string]any{"access_token": "token-1"},
  91. },
  92. {
  93. ID: 2,
  94. Name: "image-account-2",
  95. Platform: service.PlatformOpenAI,
  96. Type: service.AccountTypeOAuth,
  97. Status: service.StatusActive,
  98. Schedulable: true,
  99. Concurrency: 0,
  100. Priority: 1,
  101. Credentials: map[string]any{"access_token": "token-2"},
  102. },
  103. }
  104. accountRepo := openAIImagesFailoverAccountRepo{accounts: accounts}
  105. upstream := &openAIImagesFailoverHTTPUpstream{}
  106. cfg := &config.Config{RunMode: config.RunModeSimple}
  107. gatewayService := service.NewOpenAIGatewayService(
  108. accountRepo,
  109. nil,
  110. nil,
  111. nil,
  112. nil,
  113. nil,
  114. nil,
  115. cfg,
  116. nil,
  117. nil,
  118. nil,
  119. nil,
  120. nil,
  121. upstream,
  122. nil,
  123. nil,
  124. nil,
  125. nil,
  126. nil,
  127. nil,
  128. nil,
  129. nil,
  130. )
  131. billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
  132. t.Cleanup(billingService.Stop)
  133. concurrencyService := service.NewConcurrencyService(nil)
  134. handler := NewOpenAIGatewayHandler(
  135. gatewayService,
  136. concurrencyService,
  137. billingService,
  138. service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg),
  139. nil,
  140. nil,
  141. nil,
  142. nil,
  143. cfg,
  144. )
  145. handler.maxAccountSwitches = 10
  146. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
  147. core, observedLogs := observer.New(zap.DebugLevel)
  148. requestCtx := logger.IntoContext(context.Background(), zap.New(core))
  149. req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
  150. req.Header.Set("Content-Type", "application/json")
  151. rec := httptest.NewRecorder()
  152. c, _ := gin.CreateTestContext(rec)
  153. c.Request = req
  154. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  155. ID: 99,
  156. GroupID: &groupID,
  157. Group: &service.Group{
  158. ID: groupID,
  159. AllowImageGeneration: true,
  160. },
  161. User: &service.User{ID: 100},
  162. })
  163. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
  164. handler.Images(c)
  165. accountSelectingLogs := observedLogs.FilterMessage("openai.images.account_selecting").All()
  166. require.NotEmpty(t, accountSelectingLogs)
  167. loggedFields := make(map[string]string)
  168. for _, field := range accountSelectingLogs[0].Context {
  169. loggedFields[field.Key] = field.String
  170. }
  171. require.Equal(t, "high", loggedFields["img_quality"])
  172. require.Equal(t, "1536x1024", loggedFields["img_size"])
  173. require.NotContains(t, loggedFields, "prompt")
  174. require.Equal(t, []int64{1, 2}, upstream.calls())
  175. require.Equal(t, http.StatusBadGateway, rec.Code)
  176. require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
  177. require.Equal(t, "Upstream service temporarily unavailable", gjson.GetBytes(rec.Body.Bytes(), "error.message").String())
  178. rawEvents, ok := c.Get(service.OpsUpstreamErrorsKey)
  179. require.True(t, ok)
  180. events, ok := rawEvents.([]*service.OpsUpstreamErrorEvent)
  181. require.True(t, ok)
  182. require.Len(t, events, 2)
  183. require.Equal(t, "failover", events[0].Kind)
  184. require.Equal(t, "failover", events[1].Kind)
  185. }