openai_gateway_service_codex_cli_only_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. type stubCodexRestrictionDetector struct {
  16. result CodexClientRestrictionDetectionResult
  17. }
  18. func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account, _ CodexRestrictionPolicy, _ []byte) CodexClientRestrictionDetectionResult {
  19. return s.result
  20. }
  21. func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) {
  22. gin.SetMode(gin.TestMode)
  23. t.Run("使用注入的 detector", func(t *testing.T) {
  24. expected := &stubCodexRestrictionDetector{
  25. result: CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: "stub"},
  26. }
  27. svc := &OpenAIGatewayService{codexDetector: expected}
  28. got := svc.getCodexClientRestrictionDetector()
  29. require.Same(t, expected, got)
  30. })
  31. t.Run("service 为 nil 时返回默认 detector", func(t *testing.T) {
  32. var svc *OpenAIGatewayService
  33. got := svc.getCodexClientRestrictionDetector()
  34. require.NotNil(t, got)
  35. })
  36. t.Run("service 未注入 detector 时返回默认 detector", func(t *testing.T) {
  37. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: true}}}
  38. got := svc.getCodexClientRestrictionDetector()
  39. require.NotNil(t, got)
  40. rec := httptest.NewRecorder()
  41. c, _ := gin.CreateTestContext(rec)
  42. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  43. c.Request.Header.Set("User-Agent", "curl/8.0")
  44. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  45. result := got.Detect(c, account, CodexRestrictionPolicy{}, nil)
  46. require.True(t, result.Enabled)
  47. require.True(t, result.Matched)
  48. require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason)
  49. })
  50. }
  51. func TestOpenAIGatewayService_Forward_VersionGateMessage(t *testing.T) {
  52. gin.SetMode(gin.TestMode)
  53. newCtx := func() (*httptest.ResponseRecorder, *gin.Context) {
  54. rec := httptest.NewRecorder()
  55. c, _ := gin.CreateTestContext(rec)
  56. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  57. return rec, c
  58. }
  59. account := func() *Account {
  60. return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  61. }
  62. body := []byte(`{"model":"gpt-5.1-codex"}`)
  63. t.Run("版本太低:返回带版本号的差异化文案", func(t *testing.T) {
  64. rec, c := newCtx()
  65. svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{
  66. Enabled: true,
  67. Matched: false,
  68. Reason: CodexClientRestrictionReasonVersionTooLow,
  69. DetectedVersion: "0.39.0",
  70. MinCodexVersion: "0.42.0",
  71. }}}
  72. _, err := svc.Forward(context.Background(), c, account(), body)
  73. require.Error(t, err)
  74. require.Equal(t, http.StatusForbidden, rec.Code)
  75. require.Contains(t, rec.Body.String(), "Your Codex version (0.39.0) is below the minimum required version (0.42.0)")
  76. require.NotContains(t, rec.Body.String(), "This account only allows Codex official clients")
  77. })
  78. t.Run("未命中官方:仍返回通用兜底文案", func(t *testing.T) {
  79. rec, c := newCtx()
  80. svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{
  81. Enabled: true,
  82. Matched: false,
  83. Reason: CodexClientRestrictionReasonNotMatchedUA,
  84. }}}
  85. _, err := svc.Forward(context.Background(), c, account(), body)
  86. require.Error(t, err)
  87. require.Equal(t, http.StatusForbidden, rec.Code)
  88. require.Contains(t, rec.Body.String(), "This account only allows Codex official clients")
  89. })
  90. }
  91. func TestGetAPIKeyIDFromContext(t *testing.T) {
  92. gin.SetMode(gin.TestMode)
  93. t.Run("context 为 nil", func(t *testing.T) {
  94. require.Equal(t, int64(0), getAPIKeyIDFromContext(nil))
  95. })
  96. t.Run("上下文没有 api_key", func(t *testing.T) {
  97. rec := httptest.NewRecorder()
  98. c, _ := gin.CreateTestContext(rec)
  99. require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
  100. })
  101. t.Run("api_key 类型错误", func(t *testing.T) {
  102. rec := httptest.NewRecorder()
  103. c, _ := gin.CreateTestContext(rec)
  104. c.Set("api_key", "not-api-key")
  105. require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
  106. })
  107. t.Run("api_key 指针为空", func(t *testing.T) {
  108. rec := httptest.NewRecorder()
  109. c, _ := gin.CreateTestContext(rec)
  110. var k *APIKey
  111. c.Set("api_key", k)
  112. require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
  113. })
  114. t.Run("正常读取 api_key_id", func(t *testing.T) {
  115. rec := httptest.NewRecorder()
  116. c, _ := gin.CreateTestContext(rec)
  117. c.Set("api_key", &APIKey{ID: 12345})
  118. require.Equal(t, int64(12345), getAPIKeyIDFromContext(c))
  119. })
  120. }
  121. func TestLogCodexCLIOnlyDetection_NilSafety(t *testing.T) {
  122. // 不校验日志内容,仅保证在 nil 入参下不会 panic。
  123. require.NotPanics(t, func() {
  124. logCodexCLIOnlyDetection(context.TODO(), nil, nil, 0, CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: "test"}, nil)
  125. logCodexCLIOnlyDetection(context.Background(), nil, nil, 0, CodexClientRestrictionDetectionResult{Enabled: false, Matched: false, Reason: "disabled"}, nil)
  126. })
  127. }
  128. func TestLogCodexCLIOnlyDetection_OnlyLogsRejected(t *testing.T) {
  129. logSink, restore := captureStructuredLog(t)
  130. defer restore()
  131. account := &Account{ID: 1001}
  132. logCodexCLIOnlyDetection(context.Background(), nil, account, 2002, CodexClientRestrictionDetectionResult{
  133. Enabled: true,
  134. Matched: true,
  135. Reason: CodexClientRestrictionReasonMatchedUA,
  136. }, nil)
  137. logCodexCLIOnlyDetection(context.Background(), nil, account, 2002, CodexClientRestrictionDetectionResult{
  138. Enabled: true,
  139. Matched: false,
  140. Reason: CodexClientRestrictionReasonNotMatchedUA,
  141. }, nil)
  142. require.False(t, logSink.ContainsMessage("OpenAI codex_cli_only 允许官方客户端请求"))
  143. require.True(t, logSink.ContainsMessage("OpenAI codex_cli_only 拒绝非官方客户端请求"))
  144. }
  145. func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
  146. gin.SetMode(gin.TestMode)
  147. logSink, restore := captureStructuredLog(t)
  148. defer restore()
  149. rec := httptest.NewRecorder()
  150. c, _ := gin.CreateTestContext(rec)
  151. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
  152. c.Request.RemoteAddr = "172.18.0.1:54321"
  153. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
  154. c.Request.Header.Set("Content-Type", "application/json")
  155. c.Request.Header.Set("X-Real-IP", "203.0.113.42")
  156. c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
  157. body := []byte(`{"model":"gpt-5.2","stream":false,"prompt_cache_key":"pc-123","access_token":"secret-token","input":[{"type":"text","text":"hello"}]}`)
  158. account := &Account{ID: 1001}
  159. logCodexCLIOnlyDetection(context.Background(), c, account, 2002, CodexClientRestrictionDetectionResult{
  160. Enabled: true,
  161. Matched: false,
  162. Reason: CodexClientRestrictionReasonNotMatchedUA,
  163. }, body)
  164. require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
  165. require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.2"))
  166. require.True(t, logSink.ContainsFieldValue("request_query", "trace=1"))
  167. require.True(t, logSink.ContainsFieldValue("request_client_ip", "203.0.113.42"))
  168. require.True(t, logSink.ContainsFieldValue("request_remote_addr", "172.18.0.1:54321"))
  169. require.True(t, logSink.ContainsFieldValue("request_prompt_cache_key_sha256", hashSensitiveValueForLog("pc-123")))
  170. require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
  171. require.True(t, logSink.ContainsField("request_body_size"))
  172. require.False(t, logSink.ContainsField("request_body_preview"))
  173. }
  174. func TestLogOpenAIInstructionsRequiredDebug_LogsRequestDetails(t *testing.T) {
  175. gin.SetMode(gin.TestMode)
  176. logSink, restore := captureStructuredLog(t)
  177. defer restore()
  178. rec := httptest.NewRecorder()
  179. c, _ := gin.CreateTestContext(rec)
  180. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
  181. c.Request.Header.Set("User-Agent", "curl/8.0")
  182. c.Request.Header.Set("Content-Type", "application/json")
  183. c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
  184. body := []byte(`{"model":"gpt-5.1-codex","stream":false,"prompt_cache_key":"pc-abc","access_token":"secret-token","input":[{"type":"text","text":"hello"}]}`)
  185. account := &Account{ID: 1001, Name: "codex max套餐"}
  186. logOpenAIInstructionsRequiredDebug(
  187. context.Background(),
  188. c,
  189. account,
  190. http.StatusBadRequest,
  191. "Instructions are required",
  192. body,
  193. []byte(`{"error":{"message":"Instructions are required","type":"invalid_request_error","param":"instructions","code":"missing_required_parameter"}}`),
  194. )
  195. require.True(t, logSink.ContainsMessageAtLevel("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查", "warn"))
  196. require.True(t, logSink.ContainsFieldValue("request_user_agent", "curl/8.0"))
  197. require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.1-codex"))
  198. require.True(t, logSink.ContainsFieldValue("request_query", "trace=1"))
  199. require.True(t, logSink.ContainsFieldValue("account_name", "codex max套餐"))
  200. require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
  201. require.True(t, logSink.ContainsField("request_body_size"))
  202. require.False(t, logSink.ContainsField("request_body_preview"))
  203. }
  204. func TestLogOpenAIInstructionsRequiredDebug_NonTargetErrorSkipped(t *testing.T) {
  205. gin.SetMode(gin.TestMode)
  206. logSink, restore := captureStructuredLog(t)
  207. defer restore()
  208. rec := httptest.NewRecorder()
  209. c, _ := gin.CreateTestContext(rec)
  210. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  211. c.Request.Header.Set("User-Agent", "curl/8.0")
  212. body := []byte(`{"model":"gpt-5.1-codex","stream":false}`)
  213. logOpenAIInstructionsRequiredDebug(
  214. context.Background(),
  215. c,
  216. &Account{ID: 1001},
  217. http.StatusForbidden,
  218. "forbidden",
  219. body,
  220. []byte(`{"error":{"message":"forbidden"}}`),
  221. )
  222. require.False(t, logSink.ContainsMessage("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查"))
  223. }
  224. func TestIsOpenAITransientProcessingError(t *testing.T) {
  225. require.True(t, isOpenAITransientProcessingError(
  226. http.StatusBadRequest,
  227. "An error occurred while processing your request.",
  228. nil,
  229. ))
  230. require.True(t, isOpenAITransientProcessingError(
  231. http.StatusBadRequest,
  232. "Selected model is at capacity. Please try a different model.",
  233. []byte(`{"error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`),
  234. ))
  235. require.True(t, isOpenAITransientProcessingError(
  236. http.StatusBadRequest,
  237. "",
  238. []byte(`{"error":{"code":"server_is_overloaded","message":"Please retry later.","type":"invalid_request_error"}}`),
  239. ))
  240. require.True(t, isOpenAITransientProcessingError(
  241. http.StatusServiceUnavailable,
  242. "",
  243. []byte(`{"error":{"code":"slow_down","message":"Please retry later."}}`),
  244. ))
  245. require.True(t, isOpenAITransientProcessingError(
  246. http.StatusBadRequest,
  247. "",
  248. []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`),
  249. ))
  250. require.True(t, isOpenAITransientProcessingError(
  251. http.StatusServiceUnavailable,
  252. "Server is overloaded. Please try again later.",
  253. nil,
  254. ))
  255. require.True(t, isOpenAITransientProcessingError(
  256. http.StatusBadGateway,
  257. "",
  258. []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`),
  259. ))
  260. require.True(t, isOpenAITransientProcessingError(
  261. http.StatusBadRequest,
  262. "",
  263. []byte(`{"error":{"message":"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_123 in your message."}}`),
  264. ))
  265. require.False(t, isOpenAITransientProcessingError(
  266. http.StatusBadRequest,
  267. "Missing required parameter: 'instructions'",
  268. []byte(`{"error":{"message":"Missing required parameter: 'instructions'"}}`),
  269. ))
  270. }
  271. func TestIsOpenAIContextWindowError(t *testing.T) {
  272. require.True(t, isOpenAIContextWindowError(
  273. "",
  274. []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`),
  275. ))
  276. require.True(t, isOpenAIContextWindowError(
  277. "maximum context length exceeded",
  278. nil,
  279. ))
  280. require.True(t, isOpenAIContextWindowError(
  281. "",
  282. []byte(`maximum context length exceeded`),
  283. ))
  284. require.False(t, isOpenAIContextWindowError(
  285. "context canceled",
  286. nil,
  287. ))
  288. require.False(t, isOpenAIContextWindowError(
  289. "upstream unavailable",
  290. []byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"context_length_exceeded maximum context length"}`),
  291. ))
  292. }
  293. func TestOpenAITransientAndCapacityClassificationIgnoresEchoedJSON(t *testing.T) {
  294. body := []byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"server is overloaded; selected model is at capacity"}`)
  295. require.False(t, isOpenAITransientProcessingError(http.StatusBadRequest, "upstream unavailable", body))
  296. require.False(t, isOpenAIRequestScopedCapacityShed("upstream unavailable", body))
  297. plainText := []byte(`server is overloaded; please retry later`)
  298. require.True(t, isOpenAITransientProcessingError(http.StatusServiceUnavailable, "", plainText))
  299. require.True(t, isOpenAIRequestScopedCapacityShed("", plainText))
  300. }
  301. func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) {
  302. svc := &OpenAIGatewayService{}
  303. body := []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`)
  304. require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "", body))
  305. require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`)))
  306. require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(),
  307. http.StatusBadGateway,
  308. "temporary upstream outage",
  309. []byte(`{"error":{"message":"temporary upstream outage"},"echo":"context_length_exceeded"}`),
  310. ))
  311. }
  312. func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) {
  313. gin.SetMode(gin.TestMode)
  314. logSink, restore := captureStructuredLog(t)
  315. defer restore()
  316. rec := httptest.NewRecorder()
  317. c, _ := gin.CreateTestContext(rec)
  318. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
  319. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
  320. c.Request.Header.Set("Content-Type", "application/json")
  321. c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
  322. upstream := &httpUpstreamRecorder{
  323. resp: &http.Response{
  324. StatusCode: http.StatusBadRequest,
  325. Header: http.Header{
  326. "Content-Type": []string{"application/json"},
  327. "x-request-id": []string{"rid-upstream"},
  328. },
  329. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Missing required parameter: 'instructions'","type":"invalid_request_error","param":"instructions","code":"missing_required_parameter"}}`)),
  330. },
  331. }
  332. svc := &OpenAIGatewayService{
  333. cfg: &config.Config{
  334. Gateway: config.GatewayConfig{ForceCodexCLI: false},
  335. },
  336. httpUpstream: upstream,
  337. }
  338. account := &Account{
  339. ID: 1001,
  340. Name: "codex max套餐",
  341. Platform: PlatformOpenAI,
  342. Type: AccountTypeAPIKey,
  343. Concurrency: 1,
  344. Credentials: map[string]any{"api_key": "sk-test"},
  345. Status: StatusActive,
  346. Schedulable: true,
  347. RateMultiplier: f64p(1),
  348. }
  349. body := []byte(`{"model":"gpt-5.1-codex","stream":false,"input":[{"type":"text","text":"hello"}],"prompt_cache_key":"pc-forward","access_token":"secret-token"}`)
  350. _, err := svc.Forward(context.Background(), c, account, body)
  351. require.Error(t, err)
  352. // missing_required_parameter 是确定性的请求错误:换账号、重试都不会变。按真实的
  353. // 400 回写并保留 param/code,客户端才知道该补哪个字段(而不是收到可重试的 502)。
  354. require.Equal(t, http.StatusBadRequest, rec.Code)
  355. require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String())
  356. require.Equal(t, "missing_required_parameter", gjson.Get(rec.Body.String(), "error.code").String())
  357. require.Equal(t, "instructions", gjson.Get(rec.Body.String(), "error.param").String())
  358. require.Contains(t, err.Error(), "upstream error: 400")
  359. require.True(t, logSink.ContainsMessageAtLevel("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查", "warn"))
  360. require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.1.0"))
  361. require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.1-codex"))
  362. require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
  363. require.True(t, logSink.ContainsField("request_body_size"))
  364. require.False(t, logSink.ContainsField("request_body_preview"))
  365. }
  366. func TestOpenAIGatewayService_Forward_TransientProcessingErrorTriggersFailover(t *testing.T) {
  367. gin.SetMode(gin.TestMode)
  368. rec := httptest.NewRecorder()
  369. c, _ := gin.CreateTestContext(rec)
  370. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  371. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
  372. c.Request.Header.Set("Content-Type", "application/json")
  373. upstream := &httpUpstreamRecorder{
  374. resp: &http.Response{
  375. StatusCode: http.StatusBadRequest,
  376. Header: http.Header{
  377. "Content-Type": []string{"application/json"},
  378. "x-request-id": []string{"rid-processing-400"},
  379. },
  380. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_123 in your message.","type":"invalid_request_error"}}`)),
  381. },
  382. }
  383. svc := &OpenAIGatewayService{
  384. cfg: &config.Config{
  385. Gateway: config.GatewayConfig{ForceCodexCLI: false},
  386. },
  387. httpUpstream: upstream,
  388. }
  389. account := &Account{
  390. ID: 1001,
  391. Name: "codex max套餐",
  392. Platform: PlatformOpenAI,
  393. Type: AccountTypeAPIKey,
  394. Concurrency: 1,
  395. Credentials: map[string]any{"api_key": "sk-test"},
  396. Status: StatusActive,
  397. Schedulable: true,
  398. RateMultiplier: f64p(1),
  399. }
  400. body := []byte(`{"model":"gpt-5.1-codex","stream":false,"input":[{"type":"text","text":"hello"}]}`)
  401. _, err := svc.Forward(context.Background(), c, account, body)
  402. require.Error(t, err)
  403. var failoverErr *UpstreamFailoverError
  404. require.ErrorAs(t, err, &failoverErr)
  405. require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode)
  406. require.Contains(t, string(failoverErr.ResponseBody), "An error occurred while processing your request")
  407. require.False(t, c.Writer.Written(), "service 层应返回 failover 错误给上层换号,而不是直接向客户端写响应")
  408. }
  409. func TestOpenAIGatewayService_Forward_ModelCapacityErrorTriggersFailoverAndSameAccountRetry(t *testing.T) {
  410. gin.SetMode(gin.TestMode)
  411. rec := httptest.NewRecorder()
  412. c, _ := gin.CreateTestContext(rec)
  413. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  414. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
  415. c.Request.Header.Set("Content-Type", "application/json")
  416. upstream := &httpUpstreamRecorder{
  417. resp: &http.Response{
  418. StatusCode: http.StatusBadRequest,
  419. Header: http.Header{
  420. "Content-Type": []string{"application/json"},
  421. "x-request-id": []string{"rid-capacity-400"},
  422. },
  423. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`)),
  424. },
  425. }
  426. svc := &OpenAIGatewayService{
  427. cfg: &config.Config{
  428. Gateway: config.GatewayConfig{ForceCodexCLI: false},
  429. },
  430. httpUpstream: upstream,
  431. }
  432. account := &Account{
  433. ID: 1001,
  434. Name: "codex max套餐",
  435. Platform: PlatformOpenAI,
  436. Type: AccountTypeAPIKey,
  437. Concurrency: 1,
  438. Credentials: map[string]any{
  439. "api_key": "sk-test",
  440. "pool_mode": true,
  441. },
  442. Status: StatusActive,
  443. Schedulable: true,
  444. RateMultiplier: f64p(1),
  445. }
  446. body := []byte(`{"model":"gpt-5.4","stream":false,"input":[{"type":"text","text":"hello"}]}`)
  447. _, err := svc.Forward(context.Background(), c, account, body)
  448. require.Error(t, err)
  449. var failoverErr *UpstreamFailoverError
  450. require.ErrorAs(t, err, &failoverErr)
  451. require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode)
  452. require.True(t, failoverErr.RetryableOnSameAccount)
  453. require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity")
  454. require.False(t, c.Writer.Written(), "service 层应返回 failover 错误给上层重试/换号,而不是直接向客户端写响应")
  455. }