openai_upstream_client_error_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/model"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. // issue #5479 上游返回的原始错误体:Codex Desktop 的 automation_update 工具定义沉进
  17. // 会话历史后,OpenAI 每一轮都确定性地拒收。
  18. const openAIInvalidFunctionParametersBody = `{"error":{` +
  19. `"message":"Invalid schema for function 'automation_update': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"None\"'.",` +
  20. `"type":"invalid_request_error",` +
  21. `"param":"input[8].tools[1].tools[2].parameters",` +
  22. `"code":"invalid_function_parameters"}}`
  23. func newOpenAIUpstreamErrorTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
  24. t.Helper()
  25. gin.SetMode(gin.TestMode)
  26. rec := httptest.NewRecorder()
  27. c, _ := gin.CreateTestContext(rec)
  28. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  29. return c, rec
  30. }
  31. func newOpenAIUpstreamErrorResponse(statusCode int, body string) *http.Response {
  32. return &http.Response{
  33. StatusCode: statusCode,
  34. Header: http.Header{"Content-Type": []string{"application/json"}},
  35. Body: io.NopCloser(strings.NewReader(body)),
  36. }
  37. }
  38. func newOpenAIUpstreamErrorTestAccount() *Account {
  39. return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acct"}
  40. }
  41. type modelNotFoundManagedAccountRepo struct{ AccountRepository }
  42. func TestOpenAICompatibleModelNotFound400FailoverScope(t *testing.T) {
  43. svc := &OpenAIGatewayService{accountRepo: &modelNotFoundManagedAccountRepo{}}
  44. body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
  45. for _, tc := range []struct {
  46. name string
  47. account *Account
  48. want bool
  49. }{
  50. {name: "openai api key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: true},
  51. {name: "compatible provider", account: &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, want: true},
  52. {name: "anthropic account", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, want: false},
  53. {name: "missing account", account: nil, want: false},
  54. } {
  55. t.Run(tc.name, func(t *testing.T) {
  56. require.Equal(t, tc.want, svc.shouldFailoverOpenAIUpstreamResponse(
  57. tc.account, http.StatusBadRequest, "model not found", body,
  58. ))
  59. })
  60. }
  61. require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(
  62. &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  63. http.StatusBadRequest,
  64. "Invalid value for temperature",
  65. []byte(`{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`),
  66. ))
  67. }
  68. func TestOpenAICompatibleModelNotFound400WithoutManagedCandidatesRemainsTerminal(t *testing.T) {
  69. svc := &OpenAIGatewayService{}
  70. body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
  71. require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(
  72. &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  73. http.StatusBadRequest,
  74. "model not found",
  75. body,
  76. ))
  77. }
  78. func TestFailoverOpenAIUpstreamHTTPError_ModelNotFoundIsNextAccountEligible(t *testing.T) {
  79. c, _ := newOpenAIUpstreamErrorTestContext(t)
  80. svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: &modelNotFoundManagedAccountRepo{}}
  81. account := &Account{ID: 42, Name: "compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  82. body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
  83. resp := newOpenAIUpstreamErrorResponse(http.StatusBadRequest, string(body))
  84. failoverErr := svc.failoverOpenAIUpstreamHTTPError(
  85. context.Background(), c, account, resp, body, "model not found", "missing-model",
  86. )
  87. require.NotNil(t, failoverErr)
  88. require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode)
  89. require.Equal(t, body, failoverErr.ResponseBody)
  90. require.True(t, failoverErr.ShouldRetryNextAccount())
  91. require.False(t, IsResponseCommitted(c), "failover must remain eligible before writing downstream")
  92. }
  93. // 主复现:原生 Responses 路径必须回真实的 400 与上游诊断信息,而不是可重试的 502。
  94. //
  95. // 归一成 502 时下游网关(CCH 等)会把确定性的 Schema 错误当成临时上游故障重试,
  96. // issue #5479 实测 30 个失败请求被放大成 60 次上游调用。
  97. func TestHandleErrorResponse_Deterministic400IsNotRewrappedAs502(t *testing.T) {
  98. c, rec := newOpenAIUpstreamErrorTestContext(t)
  99. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  100. _, err := svc.handleErrorResponse(
  101. context.Background(),
  102. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody),
  103. c, newOpenAIUpstreamErrorTestAccount(), nil,
  104. )
  105. require.Error(t, err)
  106. require.Equal(t, http.StatusBadRequest, rec.Code, "确定性 400 不得被包成可重试的 502")
  107. body := rec.Body.String()
  108. require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String())
  109. require.Equal(t, "invalid_function_parameters", gjson.Get(body, "error.code").String())
  110. require.Equal(t, "input[8].tools[1].tools[2].parameters", gjson.Get(body, "error.param").String(),
  111. "param 是客户端定位哪个字段非法的唯一线索")
  112. require.Contains(t, gjson.Get(body, "error.message").String(), "Invalid schema for function 'automation_update'")
  113. require.NotContains(t, body, "Upstream request failed")
  114. // 确定性请求错误不该换号重试——换任何账号都是同样的结果。
  115. var failoverErr *UpstreamFailoverError
  116. require.False(t, errors.As(err, &failoverErr), "400 不得触发 failover")
  117. }
  118. // 不变式:同一份上游错误体,原生 Responses 与 ChatCompletions/Anthropic 兼容路径
  119. // 必须给出同样的状态码和同样的 message。这两条路径在同一个 service 上,之前一条对
  120. // 一条错,正是本次修复的根因;锁死对称性避免将来只改一边。
  121. func TestHandleErrorResponse_MatchesCompatSiblingForDeterministic400(t *testing.T) {
  122. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  123. nativeCtx, nativeRec := newOpenAIUpstreamErrorTestContext(t)
  124. _, nativeErr := svc.handleErrorResponse(
  125. context.Background(),
  126. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody),
  127. nativeCtx, newOpenAIUpstreamErrorTestAccount(), nil,
  128. )
  129. require.Error(t, nativeErr)
  130. compatCtx, _ := newOpenAIUpstreamErrorTestContext(t)
  131. var compatStatus int
  132. var compatType, compatMsg string
  133. writeError := func(_ *gin.Context, statusCode int, errType, message string) {
  134. compatStatus, compatType, compatMsg = statusCode, errType, message
  135. }
  136. _, compatErr := svc.handleCompatErrorResponse(
  137. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody),
  138. compatCtx, newOpenAIUpstreamErrorTestAccount(), writeError,
  139. )
  140. require.Error(t, compatErr)
  141. require.Equal(t, compatStatus, nativeRec.Code, "两条路径的状态码必须一致")
  142. require.Equal(t, compatType, gjson.Get(nativeRec.Body.String(), "error.type").String(),
  143. "两条路径的 error.type 必须一致")
  144. require.Equal(t, compatMsg, gjson.Get(nativeRec.Body.String(), "error.message").String(),
  145. "两条路径的 message 必须一致")
  146. }
  147. // 上游只给 message、没有 type/code/param 时,仍要回 400 + 真实 message,
  148. // 缺失字段用 OpenAI 惯例兜底,不得凭空编造 code/param。
  149. func TestHandleErrorResponse_Deterministic400WithoutUpstreamMetadata(t *testing.T) {
  150. c, rec := newOpenAIUpstreamErrorTestContext(t)
  151. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  152. _, err := svc.handleErrorResponse(
  153. context.Background(),
  154. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, `{"error":{"message":"Invalid 'input': expected an array."}}`),
  155. c, newOpenAIUpstreamErrorTestAccount(), nil,
  156. )
  157. require.Error(t, err)
  158. require.Equal(t, http.StatusBadRequest, rec.Code)
  159. body := rec.Body.String()
  160. require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String())
  161. require.Equal(t, "Invalid 'input': expected an array.", gjson.Get(body, "error.message").String())
  162. require.False(t, gjson.Get(body, "error.code").Exists(), "上游没给 code 就不要编一个")
  163. require.False(t, gjson.Get(body, "error.param").Exists(), "上游没给 param 就不要编一个")
  164. }
  165. // 上游回非 JSON(反代的 HTML 错误页等)时不得 panic,也不得回空 message。
  166. func TestHandleErrorResponse_Deterministic400WithNonJSONBody(t *testing.T) {
  167. c, rec := newOpenAIUpstreamErrorTestContext(t)
  168. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  169. _, err := svc.handleErrorResponse(
  170. context.Background(),
  171. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, `<html><body>400 Bad Request</body></html>`),
  172. c, newOpenAIUpstreamErrorTestAccount(), nil,
  173. )
  174. require.Error(t, err)
  175. require.Equal(t, http.StatusBadRequest, rec.Code)
  176. body := rec.Body.String()
  177. require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String())
  178. require.NotEmpty(t, gjson.Get(body, "error.message").String())
  179. }
  180. // 作用域守卫:本次只放行 400。其余落到 default 的状态码必须维持原样,
  181. // 避免后续有人顺手把 404/422/5xx 一起改掉。
  182. func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T) {
  183. cases := []struct {
  184. name string
  185. statusCode int
  186. body string
  187. wantStatus int
  188. wantType string
  189. wantMsg string
  190. }{
  191. // 404/405 可能是上游 base_url 配错(运营方问题),不当成客户端错误暴露。
  192. {"not_found", http.StatusNotFound, `{"error":{"message":"Unknown request URL"}}`,
  193. http.StatusBadGateway, "upstream_error", "Upstream request failed"},
  194. {"unprocessable", http.StatusUnprocessableEntity, `{"error":{"message":"Invalid schema for field messages"}}`,
  195. http.StatusBadGateway, "upstream_error", "Upstream request failed"},
  196. // 401/402/403 是网关运营方的凭据/账单问题,必须继续对客户端屏蔽上游账号状态。
  197. // 403 的自由文本不能升级成 durable access-state typed failover;只有明确结构化 code 才可以。
  198. {"unauthorized", http.StatusUnauthorized, `{"error":{"message":"Incorrect API key provided: sk-abc"}}`,
  199. http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"},
  200. {"forbidden", http.StatusForbidden, `{"error":{"message":"Your account is deactivated"}}`,
  201. http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"},
  202. // 429 保持独立映射。
  203. {"rate_limited", http.StatusTooManyRequests, `{"error":{"message":"Rate limit reached"}}`,
  204. http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"},
  205. }
  206. for _, tc := range cases {
  207. t.Run(tc.name, func(t *testing.T) {
  208. c, rec := newOpenAIUpstreamErrorTestContext(t)
  209. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  210. _, err := svc.handleErrorResponse(
  211. context.Background(),
  212. newOpenAIUpstreamErrorResponse(tc.statusCode, tc.body),
  213. c, newOpenAIUpstreamErrorTestAccount(), nil,
  214. )
  215. require.Error(t, err)
  216. if tc.name == "forbidden" {
  217. var failoverErr *UpstreamFailoverError
  218. require.False(t, errors.As(err, &failoverErr))
  219. }
  220. require.Equal(t, tc.wantStatus, rec.Code)
  221. require.Equal(t, tc.wantType, gjson.Get(rec.Body.String(), "error.type").String())
  222. require.Equal(t, tc.wantMsg, gjson.Get(rec.Body.String(), "error.message").String())
  223. })
  224. }
  225. }
  226. // 顺序守卫:管理员配置的错误透传规则在更上游命中,新分支不得抢在它前面。
  227. func TestHandleErrorResponse_PassthroughRuleStillWinsOver400Branch(t *testing.T) {
  228. c, rec := newOpenAIUpstreamErrorTestContext(t)
  229. ruleSvc := &ErrorPassthroughService{}
  230. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{
  231. newNonFailoverPassthroughRule(http.StatusBadRequest, "automation_update", http.StatusTeapot, "自定义文案"),
  232. })
  233. BindErrorPassthroughService(c, ruleSvc)
  234. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  235. _, err := svc.handleErrorResponse(
  236. context.Background(),
  237. newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody),
  238. c, newOpenAIUpstreamErrorTestAccount(), nil,
  239. )
  240. require.Error(t, err)
  241. require.Equal(t, http.StatusTeapot, rec.Code, "命中透传规则时必须按规则的状态码回写")
  242. require.Equal(t, "自定义文案", gjson.Get(rec.Body.String(), "error.message").String())
  243. }
  244. func TestIsOpenAIDeterministicClientError(t *testing.T) {
  245. require.True(t, isOpenAIDeterministicClientError(http.StatusBadRequest))
  246. for _, status := range []int{
  247. http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden,
  248. http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge,
  249. http.StatusUnprocessableEntity, http.StatusTooManyRequests,
  250. http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
  251. } {
  252. require.False(t, isOpenAIDeterministicClientError(status), "status %d", status)
  253. }
  254. }
  255. func TestWriteOpenAIUpstreamClientError_PayloadShape(t *testing.T) {
  256. cases := []struct {
  257. name string
  258. body string
  259. upstreamMsg string
  260. wantType string
  261. wantCode string
  262. wantParam string
  263. wantMessage string
  264. }{
  265. {
  266. name: "full_metadata",
  267. body: openAIInvalidFunctionParametersBody,
  268. upstreamMsg: "Invalid schema for function 'automation_update'",
  269. wantType: "invalid_request_error",
  270. wantCode: "invalid_function_parameters",
  271. wantParam: "input[8].tools[1].tools[2].parameters",
  272. wantMessage: "Invalid schema for function 'automation_update'",
  273. },
  274. {
  275. name: "upstream_type_preserved",
  276. body: `{"error":{"type":"invalid_prompt","message":"blocked"}}`,
  277. upstreamMsg: "blocked",
  278. wantType: "invalid_prompt",
  279. wantMessage: "blocked",
  280. },
  281. {
  282. name: "empty_body_falls_back",
  283. body: ``,
  284. upstreamMsg: "",
  285. wantType: "invalid_request_error",
  286. wantMessage: openAIUpstreamClientErrorFallbackMessage,
  287. },
  288. {
  289. // 调用方传入的 message 已脱敏,必须原样使用,不得回落读取原始 body。
  290. name: "sanitized_message_wins_over_raw_body",
  291. body: `{"error":{"message":"failed for key=secret123"}}`,
  292. upstreamMsg: "failed for key=***",
  293. wantType: "invalid_request_error",
  294. wantMessage: "failed for key=***",
  295. },
  296. }
  297. for _, tc := range cases {
  298. t.Run(tc.name, func(t *testing.T) {
  299. c, rec := newOpenAIUpstreamErrorTestContext(t)
  300. writeOpenAIUpstreamClientError(c, http.StatusBadRequest, []byte(tc.body), tc.upstreamMsg)
  301. require.Equal(t, http.StatusBadRequest, rec.Code)
  302. body := rec.Body.String()
  303. require.Equal(t, tc.wantType, gjson.Get(body, "error.type").String())
  304. require.Equal(t, tc.wantMessage, gjson.Get(body, "error.message").String())
  305. if tc.wantCode == "" {
  306. require.False(t, gjson.Get(body, "error.code").Exists())
  307. } else {
  308. require.Equal(t, tc.wantCode, gjson.Get(body, "error.code").String())
  309. }
  310. if tc.wantParam == "" {
  311. require.False(t, gjson.Get(body, "error.param").Exists())
  312. } else {
  313. require.Equal(t, tc.wantParam, gjson.Get(body, "error.param").String())
  314. }
  315. require.NotContains(t, body, "secret123", "原始 body 里的敏感串不得泄漏")
  316. })
  317. }
  318. }