openai_gateway_response_failed_passthrough_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/model"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. "github.com/tidwall/gjson"
  19. )
  20. func buildContextLengthFailedSSE() string {
  21. failed := `{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"code":"context_length_exceeded","type":"invalid_request_error","message":"Your input exceeds the context window of this model. Please adjust your input and try again."},"output":[],"usage":{"input_tokens":100000,"output_tokens":0,"total_tokens":100000}}}`
  22. return fmt.Sprintf("data: %s\n\n", failed)
  23. }
  24. func bindPassthroughRule(c *gin.Context, platform string, keywords []string, responseCode int) {
  25. svc := &ErrorPassthroughService{}
  26. rules := make([]*cachedPassthroughRule, 0, len(keywords))
  27. for i, kw := range keywords {
  28. code := responseCode
  29. rules = append(rules, &cachedPassthroughRule{
  30. ErrorPassthroughRule: &model.ErrorPassthroughRule{
  31. ID: int64(i + 1),
  32. Enabled: true,
  33. Platforms: []string{platform},
  34. MatchMode: model.MatchModeAny,
  35. Keywords: []string{kw},
  36. ResponseCode: &code,
  37. PassthroughBody: true,
  38. },
  39. lowerKeywords: []string{strings.ToLower(kw)},
  40. lowerPlatforms: []string{strings.ToLower(platform)},
  41. })
  42. }
  43. svc.localCacheMu.Lock()
  44. svc.localCache = rules
  45. svc.localCacheMu.Unlock()
  46. BindErrorPassthroughService(c, svc)
  47. }
  48. func TestForwardAsChatCompletions_ResponseFailed_PassthroughRule(t *testing.T) {
  49. gin.SetMode(gin.TestMode)
  50. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  51. rec := httptest.NewRecorder()
  52. c, _ := gin.CreateTestContext(rec)
  53. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  54. c.Request.Header.Set("Content-Type", "application/json")
  55. bindPassthroughRule(c, "openai", []string{"context_length_exceeded"}, 400)
  56. upstream := &httpUpstreamRecorder{resp: &http.Response{
  57. StatusCode: http.StatusOK,
  58. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  59. Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
  60. }}
  61. svc := &OpenAIGatewayService{
  62. cfg: rawChatCompletionsTestConfig(),
  63. httpUpstream: upstream,
  64. }
  65. account := rawChatCompletionsTestAccount()
  66. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  67. require.Error(t, err)
  68. require.Contains(t, err.Error(), "passthrough")
  69. require.Equal(t, 400, rec.Code, "passthrough rule should override 502 to 400")
  70. respBody := rec.Body.String()
  71. errType := gjson.Get(respBody, "error.type").String()
  72. require.Equal(t, "upstream_error", errType)
  73. errMsg := gjson.Get(respBody, "error.message").String()
  74. require.NotEmpty(t, errMsg, "passthrough should preserve error message")
  75. require.Contains(t, errMsg, "context window")
  76. }
  77. func TestResponsesStreamAccessStateFailoverPrecedesPassthroughRule(t *testing.T) {
  78. gin.SetMode(gin.TestMode)
  79. stream := "event: response.failed\n" +
  80. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"account_disabled","message":"Your account is disabled"}}}` + "\n\n"
  81. tests := []struct {
  82. name string
  83. run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
  84. }{
  85. {
  86. name: "native",
  87. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  88. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
  89. return err
  90. },
  91. },
  92. {
  93. name: "passthrough",
  94. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  95. _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
  96. return err
  97. },
  98. },
  99. }
  100. for _, tt := range tests {
  101. t.Run(tt.name, func(t *testing.T) {
  102. rec := httptest.NewRecorder()
  103. c, _ := gin.CreateTestContext(rec)
  104. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  105. bindPassthroughRule(c, PlatformOpenAI, []string{"account is disabled"}, http.StatusTeapot)
  106. resp := &http.Response{
  107. StatusCode: http.StatusOK,
  108. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  109. Body: io.NopCloser(strings.NewReader(stream)),
  110. }
  111. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
  112. err := tt.run(svc, c, resp, &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
  113. var failoverErr *UpstreamFailoverError
  114. require.ErrorAs(t, err, &failoverErr)
  115. require.True(t, failoverErr.IsCredentialFailure())
  116. require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
  117. require.False(t, failoverErr.RetryableOnSameAccount)
  118. require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
  119. require.False(t, c.Writer.Written(), "passthrough rule must not commit a response before account failover")
  120. })
  121. }
  122. }
  123. func TestResponsesStreamCyberPolicyPrecedesPassthroughRule(t *testing.T) {
  124. gin.SetMode(gin.TestMode)
  125. stream := "event: error\n" +
  126. `data: {"type":"error","error":{"code":"cyber_policy","message":"blocked by cyber policy"}}` + "\n\n"
  127. tests := []struct {
  128. name string
  129. run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
  130. }{
  131. {
  132. name: "native",
  133. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  134. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
  135. return err
  136. },
  137. },
  138. {
  139. name: "passthrough",
  140. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  141. _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
  142. return err
  143. },
  144. },
  145. }
  146. for _, tt := range tests {
  147. t.Run(tt.name, func(t *testing.T) {
  148. rec := httptest.NewRecorder()
  149. c, _ := gin.CreateTestContext(rec)
  150. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  151. bindPassthroughRule(c, PlatformOpenAI, []string{"cyber policy"}, http.StatusTeapot)
  152. resp := &http.Response{
  153. StatusCode: http.StatusOK,
  154. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  155. Body: io.NopCloser(strings.NewReader(stream)),
  156. }
  157. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
  158. err := tt.run(svc, c, resp, &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
  159. require.Error(t, err)
  160. var failoverErr *UpstreamFailoverError
  161. require.False(t, errors.As(err, &failoverErr))
  162. require.NotNil(t, GetOpsCyberPolicy(c))
  163. require.NotEqual(t, http.StatusTeapot, rec.Code)
  164. require.Contains(t, rec.Body.String(), "cyber_policy")
  165. })
  166. }
  167. }
  168. func TestForwardAsAnthropic_ResponseFailed_PassthroughRule(t *testing.T) {
  169. gin.SetMode(gin.TestMode)
  170. body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  171. rec := httptest.NewRecorder()
  172. c, _ := gin.CreateTestContext(rec)
  173. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  174. c.Request.Header.Set("Content-Type", "application/json")
  175. bindPassthroughRule(c, "openai", []string{"context_length_exceeded"}, 400)
  176. upstream := &httpUpstreamRecorder{resp: &http.Response{
  177. StatusCode: http.StatusOK,
  178. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  179. Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
  180. }}
  181. svc := &OpenAIGatewayService{
  182. cfg: rawChatCompletionsTestConfig(),
  183. httpUpstream: upstream,
  184. }
  185. account := rawChatCompletionsTestAccount()
  186. _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
  187. require.Error(t, err)
  188. require.Contains(t, err.Error(), "passthrough")
  189. require.Equal(t, 400, rec.Code, "passthrough rule should override 502 to 400")
  190. respBody := rec.Body.String()
  191. errMsg := gjson.Get(respBody, "error.message").String()
  192. require.NotEmpty(t, errMsg, "passthrough should preserve error message")
  193. }
  194. func TestForwardAsChatCompletions_ResponseFailed_NoRule_Still502(t *testing.T) {
  195. gin.SetMode(gin.TestMode)
  196. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  197. rec := httptest.NewRecorder()
  198. c, _ := gin.CreateTestContext(rec)
  199. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  200. c.Request.Header.Set("Content-Type", "application/json")
  201. upstream := &httpUpstreamRecorder{resp: &http.Response{
  202. StatusCode: http.StatusOK,
  203. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  204. Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
  205. }}
  206. svc := &OpenAIGatewayService{
  207. cfg: rawChatCompletionsTestConfig(),
  208. httpUpstream: upstream,
  209. }
  210. account := rawChatCompletionsTestAccount()
  211. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  212. require.Error(t, err)
  213. require.Equal(t, http.StatusBadGateway, rec.Code, "without passthrough rule should still be 502")
  214. }
  215. // bindStatusCodePassthroughRule 绑定一条按错误码+关键词双条件(MatchModeAll)匹配的规则。
  216. // 此类规则依赖语义状态码推断才能在协议转换路径命中(response.failed 无真实 HTTP 状态码)。
  217. func bindStatusCodePassthroughRule(c *gin.Context, platform string, statusCode int, keyword string, responseCode int) {
  218. rule := &model.ErrorPassthroughRule{
  219. ID: 1,
  220. Name: "status-code-rule",
  221. Enabled: true,
  222. Priority: 1,
  223. Platforms: []string{platform},
  224. ErrorCodes: []int{statusCode},
  225. Keywords: []string{keyword},
  226. MatchMode: model.MatchModeAll,
  227. ResponseCode: &responseCode,
  228. PassthroughBody: true,
  229. }
  230. svc := &ErrorPassthroughService{}
  231. svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
  232. BindErrorPassthroughService(c, svc)
  233. }
  234. func TestForwardAsChatCompletions_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
  235. gin.SetMode(gin.TestMode)
  236. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  237. rec := httptest.NewRecorder()
  238. c, _ := gin.CreateTestContext(rec)
  239. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  240. c.Request.Header.Set("Content-Type", "application/json")
  241. bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
  242. upstream := &httpUpstreamRecorder{resp: &http.Response{
  243. StatusCode: http.StatusOK,
  244. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  245. Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
  246. }}
  247. svc := &OpenAIGatewayService{
  248. cfg: rawChatCompletionsTestConfig(),
  249. httpUpstream: upstream,
  250. }
  251. account := rawChatCompletionsTestAccount()
  252. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  253. require.Error(t, err)
  254. require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
  255. respBody := rec.Body.String()
  256. require.Equal(t, "upstream_error", gjson.Get(respBody, "error.type").String())
  257. require.Contains(t, gjson.Get(respBody, "error.message").String(), "context window")
  258. }
  259. func TestForwardAsAnthropic_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
  260. gin.SetMode(gin.TestMode)
  261. body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  262. rec := httptest.NewRecorder()
  263. c, _ := gin.CreateTestContext(rec)
  264. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  265. c.Request.Header.Set("Content-Type", "application/json")
  266. bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
  267. upstream := &httpUpstreamRecorder{resp: &http.Response{
  268. StatusCode: http.StatusOK,
  269. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  270. Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
  271. }}
  272. svc := &OpenAIGatewayService{
  273. cfg: rawChatCompletionsTestConfig(),
  274. httpUpstream: upstream,
  275. }
  276. account := rawChatCompletionsTestAccount()
  277. _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
  278. require.Error(t, err)
  279. require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
  280. respBody := rec.Body.String()
  281. require.NotEmpty(t, gjson.Get(respBody, "error.message").String())
  282. }