gateway_non_streaming_response_test.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. type nonJSONTempUnschedAccountRepo struct {
  17. AccountRepository
  18. tempUnschedCalls int
  19. tempReason string
  20. modelRateLimitCalls int
  21. modelScope string
  22. modelReason string
  23. }
  24. func (r *nonJSONTempUnschedAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
  25. r.tempUnschedCalls++
  26. r.tempReason = reason
  27. return nil
  28. }
  29. func (r *nonJSONTempUnschedAccountRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, reason ...string) error {
  30. r.modelRateLimitCalls++
  31. r.modelScope = scope
  32. if len(reason) > 0 {
  33. r.modelReason = reason[0]
  34. }
  35. return nil
  36. }
  37. func TestHandleNonStreamingResponse_NonJSON2xxTriggersFailover(t *testing.T) {
  38. gin.SetMode(gin.TestMode)
  39. rec := httptest.NewRecorder()
  40. c, _ := gin.CreateTestContext(rec)
  41. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  42. body := []byte("(upstream request failed)")
  43. resp := &http.Response{
  44. StatusCode: http.StatusOK,
  45. Header: http.Header{
  46. "Content-Type": []string{"text/plain"},
  47. "X-Request-Id": []string{"rid-invalid-json"},
  48. },
  49. Body: io.NopCloser(bytes.NewReader(body)),
  50. }
  51. svc := &GatewayService{
  52. cfg: &config.Config{},
  53. rateLimitService: &RateLimitService{},
  54. }
  55. usage, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, "claude-sonnet-4-6", "claude-sonnet-4-6")
  56. require.Nil(t, usage)
  57. var failoverErr *UpstreamFailoverError
  58. require.True(t, errors.As(err, &failoverErr))
  59. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  60. require.Equal(t, body, failoverErr.ResponseBody)
  61. require.Equal(t, "rid-invalid-json", failoverErr.ResponseHeaders.Get("x-request-id"))
  62. require.False(t, c.Writer.Written(), "invalid upstream response must not be committed before failover")
  63. }
  64. func TestHandleNonStreamingResponse_ValidJSONUnchanged(t *testing.T) {
  65. gin.SetMode(gin.TestMode)
  66. rec := httptest.NewRecorder()
  67. c, _ := gin.CreateTestContext(rec)
  68. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  69. body := []byte(`{"id":"msg_1","type":"message","usage":{"input_tokens":12,"output_tokens":7}}`)
  70. resp := &http.Response{
  71. StatusCode: http.StatusOK,
  72. Header: http.Header{"Content-Type": []string{"application/json"}},
  73. Body: io.NopCloser(bytes.NewReader(body)),
  74. }
  75. svc := &GatewayService{
  76. cfg: &config.Config{},
  77. rateLimitService: &RateLimitService{},
  78. }
  79. usage, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, "claude-sonnet-4-6", "claude-sonnet-4-6")
  80. require.NoError(t, err)
  81. require.NotNil(t, usage)
  82. require.Equal(t, 12, usage.InputTokens)
  83. require.Equal(t, 7, usage.OutputTokens)
  84. require.JSONEq(t, string(body), rec.Body.String())
  85. }
  86. func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_NonJSON2xxTriggersFailover(t *testing.T) {
  87. gin.SetMode(gin.TestMode)
  88. rec := httptest.NewRecorder()
  89. c, _ := gin.CreateTestContext(rec)
  90. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  91. body := []byte("(upstream request failed)")
  92. resp := &http.Response{
  93. StatusCode: http.StatusOK,
  94. Header: http.Header{"Content-Type": []string{"text/plain"}},
  95. Body: io.NopCloser(bytes.NewReader(body)),
  96. }
  97. svc := &GatewayService{cfg: &config.Config{}}
  98. usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2})
  99. require.Nil(t, usage)
  100. var failoverErr *UpstreamFailoverError
  101. require.True(t, errors.As(err, &failoverErr))
  102. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  103. require.Equal(t, body, failoverErr.ResponseBody)
  104. require.False(t, c.Writer.Written(), "invalid passthrough response must not be committed before failover")
  105. }
  106. func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_ValidJSONUnchanged(t *testing.T) {
  107. gin.SetMode(gin.TestMode)
  108. rec := httptest.NewRecorder()
  109. c, _ := gin.CreateTestContext(rec)
  110. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  111. body := []byte(`{"id":"msg_1","type":"message","usage":{"input_tokens":5,"output_tokens":3}}`)
  112. resp := &http.Response{
  113. StatusCode: http.StatusOK,
  114. Header: http.Header{"Content-Type": []string{"application/json"}},
  115. Body: io.NopCloser(bytes.NewReader(body)),
  116. }
  117. svc := &GatewayService{cfg: &config.Config{}}
  118. usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2})
  119. require.NoError(t, err)
  120. require.NotNil(t, usage)
  121. require.Equal(t, 5, usage.InputTokens)
  122. require.Equal(t, 3, usage.OutputTokens)
  123. require.JSONEq(t, string(body), rec.Body.String())
  124. }
  125. func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_ForceCacheBillingResponse(t *testing.T) {
  126. tests := []struct {
  127. name string
  128. body string
  129. want string
  130. }{
  131. {
  132. name: "converts input tokens for downstream billing",
  133. body: `{"id":"msg_1","type":"message","content":[{"type":"text","text":"unchanged"}],"usage":{"input_tokens":5,"output_tokens":3}}`,
  134. want: `{"id":"msg_1","type":"message","content":[{"type":"text","text":"unchanged"}],"usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":5}}`,
  135. },
  136. {
  137. name: "adds to genuine cache reads",
  138. body: `{"id":"msg_2","type":"message","usage":{"input_tokens":5,"output_tokens":3,"cache_read_input_tokens":7,"cache_creation_input_tokens":11}}`,
  139. want: `{"id":"msg_2","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":12,"cache_creation_input_tokens":11}}`,
  140. },
  141. {
  142. name: "zero input leaves response unchanged",
  143. body: `{"id":"msg_3","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":7}}`,
  144. want: `{"id":"msg_3","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":7}}`,
  145. },
  146. }
  147. for _, tt := range tests {
  148. t.Run(tt.name, func(t *testing.T) {
  149. gin.SetMode(gin.TestMode)
  150. rec := httptest.NewRecorder()
  151. c, _ := gin.CreateTestContext(rec)
  152. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  153. resp := &http.Response{
  154. StatusCode: http.StatusOK,
  155. Header: http.Header{"Content-Type": []string{"application/json"}},
  156. Body: io.NopCloser(bytes.NewBufferString(tt.body)),
  157. }
  158. svc := &GatewayService{cfg: &config.Config{}}
  159. usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(WithForceCacheBilling(context.Background()), resp, c, &Account{ID: 2})
  160. require.NoError(t, err)
  161. require.Equal(t, int(gjson.Get(tt.body, "usage.input_tokens").Int()), usage.InputTokens, "local accounting must retain the unclassified usage")
  162. require.Equal(t, int(gjson.Get(tt.body, "usage.cache_read_input_tokens").Int()), usage.CacheReadInputTokens, "local accounting must convert exactly once in RecordUsage")
  163. require.JSONEq(t, tt.want, rec.Body.String())
  164. })
  165. }
  166. }
  167. func TestHandleNonStreamingResponse_NonJSON2xxMatchesModelScopedTempUnschedulableRule(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. rec := httptest.NewRecorder()
  170. c, _ := gin.CreateTestContext(rec)
  171. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  172. repo := &nonJSONTempUnschedAccountRepo{}
  173. rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  174. svc := &GatewayService{
  175. cfg: &config.Config{},
  176. rateLimitService: rateLimitService,
  177. }
  178. account := &Account{
  179. ID: 3,
  180. Platform: PlatformAnthropic,
  181. Type: AccountTypeAPIKey,
  182. Credentials: map[string]any{
  183. "temp_unschedulable_enabled": true,
  184. "temp_unschedulable_rules": []any{
  185. map[string]any{
  186. "error_code": float64(http.StatusBadGateway),
  187. "keywords": []any{"upstream request failed"},
  188. "duration_minutes": float64(10),
  189. },
  190. },
  191. },
  192. }
  193. body := []byte("(upstream request failed)")
  194. resp := &http.Response{
  195. StatusCode: http.StatusOK,
  196. Header: http.Header{},
  197. Body: io.NopCloser(bytes.NewReader(body)),
  198. }
  199. _, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "claude-sonnet-4-6", "claude-sonnet-4-6")
  200. var failoverErr *UpstreamFailoverError
  201. require.True(t, errors.As(err, &failoverErr))
  202. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  203. require.Equal(t, body, failoverErr.ResponseBody)
  204. require.Zero(t, repo.tempUnschedCalls)
  205. require.Equal(t, 1, repo.modelRateLimitCalls)
  206. require.Equal(t, "claude-sonnet-4-6", repo.modelScope)
  207. require.Contains(t, repo.modelReason, `"status_code":502`)
  208. require.Contains(t, repo.modelReason, `"matched_keyword":"upstream request failed"`)
  209. }