openai_failover_cached_body_test.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. type panicOnReadCloser struct{}
  16. func (panicOnReadCloser) Read(_ []byte) (int, error) {
  17. panic("response body should not be reread")
  18. }
  19. func (panicOnReadCloser) Close() error { return nil }
  20. func TestOpenAIGatewayService_Forward_FailoverReparsesCachedBodyForNextAccount(t *testing.T) {
  21. gin.SetMode(gin.TestMode)
  22. tests := []struct {
  23. name string
  24. requestModel string
  25. firstMapping map[string]any
  26. secondMapping map[string]any
  27. wantFirst string
  28. wantSecond string
  29. }{
  30. {
  31. name: "both accounts have mapping",
  32. firstMapping: map[string]any{"alias-model": "base-model-a"},
  33. secondMapping: map[string]any{"alias-model": "base-model-b"},
  34. wantFirst: "base-model-a",
  35. wantSecond: "base-model-b",
  36. },
  37. {
  38. name: "first account has mapping second account has none",
  39. requestModel: "gpt-5.4-high",
  40. firstMapping: map[string]any{"gpt-5.4-high": "gpt-5.4"},
  41. wantFirst: "gpt-5.4",
  42. wantSecond: "gpt-5.4",
  43. },
  44. {
  45. name: "first account has no mapping second account has mapping",
  46. secondMapping: map[string]any{"alias-model": "base-model-b"},
  47. wantFirst: "alias-model",
  48. wantSecond: "base-model-b",
  49. },
  50. {
  51. name: "legacy context cache is ignored when mappings differ",
  52. firstMapping: map[string]any{"alias-model": "base-model-a"},
  53. secondMapping: map[string]any{"alias-model": "base-model-b"},
  54. wantFirst: "base-model-a",
  55. wantSecond: "base-model-b",
  56. },
  57. }
  58. for _, tt := range tests {
  59. t.Run(tt.name, func(t *testing.T) {
  60. requestModel := tt.requestModel
  61. if requestModel == "" {
  62. requestModel = "alias-model"
  63. }
  64. body := []byte(`{"model":"` + requestModel + `","stream":false,"instructions":"cache-test","input":"hello"}`)
  65. rec := httptest.NewRecorder()
  66. c, _ := gin.CreateTestContext(rec)
  67. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  68. c.Request.Header.Set("Content-Type", "application/json")
  69. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  70. {
  71. StatusCode: http.StatusTooManyRequests,
  72. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-failover-a"}},
  73. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","message":"rate limited"}}`)),
  74. },
  75. {
  76. StatusCode: http.StatusOK,
  77. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-ok-b"}},
  78. Body: io.NopCloser(strings.NewReader(`{"id":"resp_123","status":"completed","model":"ok","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  79. },
  80. }}
  81. svc := &OpenAIGatewayService{httpUpstream: upstream}
  82. firstAccount := openAIFailoverCachedBodyTestAccount(1, "account-a", tt.firstMapping)
  83. secondAccount := openAIFailoverCachedBodyTestAccount(2, "account-b", tt.secondMapping)
  84. _, err := svc.Forward(context.Background(), c, firstAccount, body)
  85. require.Error(t, err)
  86. var failoverErr *UpstreamFailoverError
  87. require.True(t, errors.As(err, &failoverErr))
  88. require.Len(t, upstream.bodies, 1)
  89. require.Equal(t, tt.wantFirst, gjson.GetBytes(upstream.bodies[0], "model").String())
  90. c.Set("openai_parsed_request_body", map[string]any{"model": tt.wantFirst, "stream": true})
  91. result, err := svc.Forward(context.Background(), c, secondAccount, body)
  92. require.NoError(t, err)
  93. require.NotNil(t, result)
  94. require.Len(t, upstream.bodies, 2)
  95. require.Equal(t, tt.wantSecond, gjson.GetBytes(upstream.bodies[1], "model").String())
  96. })
  97. }
  98. }
  99. func TestOpenAIGatewayService_HandleFailoverSideEffects_DoesNotRereadResponseBody(t *testing.T) {
  100. svc := &OpenAIGatewayService{}
  101. account := &Account{
  102. ID: 88,
  103. Platform: PlatformOpenAI,
  104. Type: AccountTypeOAuth,
  105. }
  106. resp := &http.Response{
  107. StatusCode: http.StatusTooManyRequests,
  108. Header: http.Header{},
  109. Body: panicOnReadCloser{},
  110. }
  111. require.NotPanics(t, func() {
  112. svc.handleFailoverSideEffects(context.Background(), resp, account, []byte(`{"error":{"type":"rate_limit_error","message":"rate limited"}}`))
  113. })
  114. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  115. require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
  116. }
  117. func TestGetOpenAIRequestBodyMap_IgnoresLegacyContextCache(t *testing.T) {
  118. gin.SetMode(gin.TestMode)
  119. rec := httptest.NewRecorder()
  120. c, _ := gin.CreateTestContext(rec)
  121. c.Set("openai_parsed_request_body", map[string]any{"model": "base-model-a", "stream": true})
  122. got, err := getOpenAIRequestBodyMap(c, []byte(`{"model":"alias-model","stream":false}`))
  123. require.NoError(t, err)
  124. require.Equal(t, "alias-model", got["model"])
  125. require.Equal(t, false, got["stream"])
  126. }
  127. func openAIFailoverCachedBodyTestAccount(id int64, name string, mapping map[string]any) *Account {
  128. credentials := map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}
  129. if mapping != nil {
  130. credentials["model_mapping"] = mapping
  131. }
  132. return &Account{
  133. ID: id,
  134. Name: name,
  135. Platform: PlatformOpenAI,
  136. Type: AccountTypeOAuth,
  137. Concurrency: 1,
  138. Credentials: credentials,
  139. Status: StatusActive,
  140. Schedulable: true,
  141. RateMultiplier: f64p(1),
  142. }
  143. }