gemini_error_policy_skipped_write_test.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. // ---------------------------------------------------------------------------
  17. // ErrorPolicySkipped 的客户端写出契约(与 OpenAI 网关路径对齐):
  18. // - 池模式:不可 failover 的 4xx 按上游原始状态码/响应体保真写出,不改写成 5xx;
  19. // - 自定义错误码未命中:统一 500 + 固定文案,上游细节只进 ops 错误日志;
  20. // - 可 failover 的状态码(两种账号)一律换号,不透传。
  21. // ---------------------------------------------------------------------------
  22. const geminiSkippedTestUpstreamMsg = "antigravity executor: invalid Gemini function call history"
  23. func geminiSkippedTestUpstreamBody() string {
  24. return `{"error":{"code":null,"message":"` + geminiSkippedTestUpstreamMsg + `","param":"","type":"invalid_request_error"}}`
  25. }
  26. func newGeminiSkippedWriteService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) {
  27. httpStub := &geminiCompatHTTPUpstreamStub{
  28. response: &http.Response{
  29. StatusCode: status,
  30. Header: http.Header{"Content-Type": []string{"application/json"}},
  31. Body: io.NopCloser(strings.NewReader(body)),
  32. },
  33. }
  34. svc := &GeminiMessagesCompatService{
  35. httpUpstream: httpStub,
  36. cfg: &config.Config{},
  37. rateLimitService: NewRateLimitService(&errorPolicyRepoStub{}, nil, &config.Config{}, nil, nil),
  38. }
  39. return svc, httpStub
  40. }
  41. func geminiPoolModeAPIKeyAccount() *Account {
  42. return &Account{
  43. ID: 700,
  44. Platform: PlatformGemini,
  45. Type: AccountTypeAPIKey,
  46. Credentials: map[string]any{
  47. "api_key": "test-key",
  48. "pool_mode": true,
  49. },
  50. }
  51. }
  52. func geminiCustomCodesAPIKeyAccount() *Account {
  53. return &Account{
  54. ID: 701,
  55. Platform: PlatformGemini,
  56. Type: AccountTypeAPIKey,
  57. Credentials: map[string]any{
  58. "api_key": "test-key",
  59. "custom_error_codes_enabled": true,
  60. "custom_error_codes": []any{float64(429)},
  61. },
  62. }
  63. }
  64. func newGeminiNativeTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
  65. t.Helper()
  66. rec := httptest.NewRecorder()
  67. c, _ := gin.CreateTestContext(rec)
  68. c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", strings.NewReader("{}"))
  69. return c, rec
  70. }
  71. func TestGeminiForwardNative_PoolModeSkipped400PassthroughRealStatus(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. upstreamBody := geminiSkippedTestUpstreamBody()
  74. svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, upstreamBody)
  75. c, rec := newGeminiNativeTestContext(t)
  76. result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(),
  77. "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
  78. require.Nil(t, result)
  79. require.Error(t, err)
  80. var failoverErr *UpstreamFailoverError
  81. require.False(t, errors.As(err, &failoverErr), "池模式 400 不应换号")
  82. require.Contains(t, err.Error(), "gemini upstream error: 400")
  83. require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400")
  84. require.Equal(t, upstreamBody, rec.Body.String(), "响应体应原样透传")
  85. }
  86. func TestGeminiForwardNative_PoolModeSkipped503Failover(t *testing.T) {
  87. gin.SetMode(gin.TestMode)
  88. svc, _ := newGeminiSkippedWriteService(http.StatusServiceUnavailable, `{"error":{"message":"Upstream service temporarily unavailable"}}`)
  89. c, rec := newGeminiNativeTestContext(t)
  90. result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(),
  91. "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
  92. require.Nil(t, result)
  93. var failoverErr *UpstreamFailoverError
  94. require.True(t, errors.As(err, &failoverErr), "池模式 503 应换号")
  95. require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
  96. require.Zero(t, rec.Body.Len(), "换号场景不应写客户端响应")
  97. }
  98. func TestGeminiForwardNative_CustomCodesMiss400HiddenAs500(t *testing.T) {
  99. gin.SetMode(gin.TestMode)
  100. svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody())
  101. c, rec := newGeminiNativeTestContext(t)
  102. result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(),
  103. "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
  104. require.Nil(t, result)
  105. require.Error(t, err)
  106. require.Contains(t, err.Error(), "not in custom error codes")
  107. require.Equal(t, http.StatusInternalServerError, rec.Code)
  108. var got map[string]any
  109. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  110. errObj, ok := got["error"].(map[string]any)
  111. require.True(t, ok)
  112. require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"])
  113. require.NotContains(t, rec.Body.String(), geminiSkippedTestUpstreamMsg, "上游细节不应透传给客户端")
  114. }
  115. func TestGeminiForwardNative_CustomCodesMiss500Failover(t *testing.T) {
  116. gin.SetMode(gin.TestMode)
  117. svc, _ := newGeminiSkippedWriteService(http.StatusInternalServerError, `{"error":{"message":"internal"}}`)
  118. c, rec := newGeminiNativeTestContext(t)
  119. result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(),
  120. "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
  121. require.Nil(t, result)
  122. var failoverErr *UpstreamFailoverError
  123. require.True(t, errors.As(err, &failoverErr), "自定义错误码未命中的 500 应换号")
  124. require.Equal(t, http.StatusInternalServerError, failoverErr.StatusCode)
  125. require.False(t, failoverErr.RetryableOnSameAccount, "非池模式不应同账号重试")
  126. require.Zero(t, rec.Body.Len())
  127. }
  128. func TestGeminiForwardAsChatCompletions_CustomCodesMiss400HiddenAs500(t *testing.T) {
  129. gin.SetMode(gin.TestMode)
  130. svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody())
  131. rec := httptest.NewRecorder()
  132. c, _ := gin.CreateTestContext(rec)
  133. body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`)
  134. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body)))
  135. result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCustomCodesAPIKeyAccount(), body)
  136. require.Nil(t, result)
  137. require.Error(t, err)
  138. require.Contains(t, err.Error(), "not in custom error codes")
  139. require.Equal(t, http.StatusInternalServerError, rec.Code)
  140. var got map[string]any
  141. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  142. errObj, ok := got["error"].(map[string]any)
  143. require.True(t, ok)
  144. require.Equal(t, "api_error", errObj["type"])
  145. require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"])
  146. }
  147. func TestGeminiForwardAsChatCompletions_PoolMode400KeepsUpstreamMessage(t *testing.T) {
  148. gin.SetMode(gin.TestMode)
  149. svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody())
  150. rec := httptest.NewRecorder()
  151. c, _ := gin.CreateTestContext(rec)
  152. body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`)
  153. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body)))
  154. result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiPoolModeAPIKeyAccount(), body)
  155. require.Nil(t, result)
  156. require.Error(t, err)
  157. require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400")
  158. var got map[string]any
  159. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  160. errObj, ok := got["error"].(map[string]any)
  161. require.True(t, ok)
  162. require.Equal(t, "invalid_request_error", errObj["type"])
  163. require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message")
  164. }
  165. func TestWriteGeminiMappedError_400KeepsUpstreamMessage(t *testing.T) {
  166. gin.SetMode(gin.TestMode)
  167. svc := &GeminiMessagesCompatService{cfg: &config.Config{}}
  168. rec := httptest.NewRecorder()
  169. c, _ := gin.CreateTestContext(rec)
  170. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  171. err := svc.writeGeminiMappedError(c, &Account{ID: 702, Platform: PlatformGemini}, http.StatusBadRequest, "req-1", []byte(geminiSkippedTestUpstreamBody()))
  172. require.Error(t, err)
  173. require.Equal(t, http.StatusBadRequest, rec.Code)
  174. var got map[string]any
  175. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
  176. errObj, ok := got["error"].(map[string]any)
  177. require.True(t, ok)
  178. require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message")
  179. }