openai_ws_http_bridge_resume_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/coder/websocket"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. func TestBuildOpenAIWSCurrentTurnRetryPayloadRejectsOrphanToolOutput(t *testing.T) {
  19. payload := []byte(`{"type":"response.create","model":"mapped-model","previous_response_id":"resp_old"}`)
  20. fullInput := []json.RawMessage{
  21. json.RawMessage(`{"type":"function_call_output","call_id":"missing_call","output":"done"}`),
  22. }
  23. retryPayload, retrySafe, err := buildOpenAIWSCurrentTurnRetryPayload(payload, fullInput, true, "gpt-5.6-sol")
  24. require.NoError(t, err)
  25. require.False(t, retrySafe)
  26. require.Nil(t, retryPayload)
  27. }
  28. func TestProxyOpenAIWSHTTPBridgeTurnLaterTurn429FailsOverBeforeClientWrite(t *testing.T) {
  29. gin.SetMode(gin.TestMode)
  30. upstream := &httpUpstreamRecorder{resp: &http.Response{
  31. StatusCode: http.StatusTooManyRequests,
  32. Header: http.Header{"Retry-After": []string{"60"}},
  33. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)),
  34. }}
  35. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  36. account := &Account{ID: 129, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
  37. recorder := httptest.NewRecorder()
  38. c, _ := gin.CreateTestContext(recorder)
  39. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  40. payload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_old","input":[{"role":"user","content":"continue"}]}`)
  41. writes := 0
  42. result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
  43. context.Background(), c, account, "access-token", payload, len(payload),
  44. "gpt-5.6-sol", "", "", "", "", 281,
  45. func([]byte) error {
  46. writes++
  47. return nil
  48. },
  49. )
  50. require.Nil(t, result)
  51. var failoverErr *UpstreamFailoverError
  52. require.ErrorAs(t, err, &failoverErr)
  53. require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
  54. require.Zero(t, writes)
  55. }
  56. func TestProxyOpenAIWSHTTPBridgeTurnLaterTurnDoesNotFailOverAfterDownstreamOutput(t *testing.T) {
  57. gin.SetMode(gin.TestMode)
  58. upstream := &httpUpstreamRecorder{resp: &http.Response{
  59. StatusCode: http.StatusOK,
  60. Header: make(http.Header),
  61. Body: io.NopCloser(strings.NewReader(
  62. "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" +
  63. "data: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"limited\"}}\n\n",
  64. )),
  65. }}
  66. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  67. account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1}
  68. recorder := httptest.NewRecorder()
  69. c, _ := gin.CreateTestContext(recorder)
  70. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  71. payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`)
  72. var writes [][]byte
  73. result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
  74. context.Background(), c, account, "sk-test", payload, len(payload),
  75. "gpt-5", "", "", "", "", 281,
  76. func(message []byte) error {
  77. writes = append(writes, append([]byte(nil), message...))
  78. return nil
  79. },
  80. )
  81. require.NotNil(t, result)
  82. require.Error(t, err)
  83. var failoverErr *UpstreamFailoverError
  84. require.False(t, errors.As(err, &failoverErr))
  85. require.Len(t, writes, 2)
  86. require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String())
  87. // P0.1: after downstream output, retain the upstream error and synthesize
  88. // the official Responses terminal so Codex clients do not see a bare error
  89. // followed by a closed stream.
  90. require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String())
  91. }
  92. func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t *testing.T) {
  93. gin.SetMode(gin.TestMode)
  94. cfg := &config.Config{}
  95. cfg.Security.URLAllowlist.Enabled = false
  96. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  97. cfg.Gateway.OpenAIWS.Enabled = true
  98. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  99. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  100. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  101. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
  102. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  103. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  104. {
  105. StatusCode: http.StatusOK,
  106. Header: http.Header{
  107. "Content-Type": []string{"text/event-stream"},
  108. openAIWSTurnStateHeader: []string{"old-account-state"},
  109. },
  110. Body: io.NopCloser(strings.NewReader(
  111. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"first-ok\"}]},{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"inspect\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
  112. )),
  113. },
  114. {
  115. StatusCode: http.StatusTooManyRequests,
  116. Header: http.Header{"Retry-After": []string{"60"}},
  117. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)),
  118. },
  119. {
  120. StatusCode: http.StatusOK,
  121. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  122. Body: io.NopCloser(strings.NewReader(
  123. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"second-ok\"}]}],\"usage\":{\"input_tokens\":4,\"output_tokens\":1}}}\n\n",
  124. )),
  125. },
  126. }}
  127. svc := &OpenAIGatewayService{
  128. cfg: cfg,
  129. httpUpstream: upstream,
  130. cache: &stubGatewayCache{},
  131. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  132. toolCorrector: NewCodexToolCorrector(),
  133. }
  134. account := &Account{
  135. ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  136. Status: StatusActive, Schedulable: true, Concurrency: 1,
  137. Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge},
  138. Credentials: map[string]any{"chatgpt_account_id": "account-a", "chatgpt_user_id": "user-a"},
  139. }
  140. nextAccount := *account
  141. nextAccount.ID = 130
  142. nextAccount.Name = "replacement"
  143. nextAccount.Credentials = map[string]any{"chatgpt_account_id": "account-b", "chatgpt_user_id": "user-b"}
  144. serverErrCh := make(chan error, 1)
  145. failoverCh := make(chan []byte, 1)
  146. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  147. conn, err := websocket.Accept(w, r, nil)
  148. if err != nil {
  149. serverErrCh <- err
  150. return
  151. }
  152. defer func() { _ = conn.CloseNow() }()
  153. rec := httptest.NewRecorder()
  154. ginCtx, _ := gin.CreateTestContext(rec)
  155. ginCtx.Request = r.Clone(r.Context())
  156. readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
  157. _, firstMessage, readErr := conn.Read(readCtx)
  158. cancel()
  159. if readErr != nil {
  160. serverErrCh <- readErr
  161. return
  162. }
  163. proxyErr := svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token-a", firstMessage, nil)
  164. var failoverErr *UpstreamFailoverError
  165. if !errors.As(proxyErr, &failoverErr) {
  166. serverErrCh <- proxyErr
  167. return
  168. }
  169. retryPayload, retryCurrentTurn := OpenAIWSCurrentTurnRetryPayload(proxyErr)
  170. if !retryCurrentTurn || len(retryPayload) == 0 {
  171. serverErrCh <- errors.New("missing current-turn retry payload")
  172. return
  173. }
  174. failoverCh <- retryPayload
  175. serverErrCh <- svc.ProxyResponsesWebSocketFromClient(
  176. r.Context(), ginCtx, conn, &nextAccount, "access-token-b", retryPayload, nil,
  177. )
  178. }))
  179. defer wsServer.Close()
  180. dialCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  181. clientConn, _, err := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
  182. cancel()
  183. require.NoError(t, err)
  184. defer func() { _ = clientConn.CloseNow() }()
  185. writeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  186. err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[{"role":"user","content":"first"}]}`))
  187. cancel()
  188. require.NoError(t, err)
  189. readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  190. _, completed, err := clientConn.Read(readCtx)
  191. cancel()
  192. require.NoError(t, err)
  193. require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String())
  194. writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
  195. err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","prompt_cache_key":"client-session","client_metadata":{"session_id":"client-session","thread_id":"client-thread"},"input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`))
  196. cancel()
  197. require.NoError(t, err)
  198. readCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
  199. _, retriedCompleted, err := clientConn.Read(readCtx)
  200. cancel()
  201. require.NoError(t, err)
  202. require.Equal(t, "response.completed", gjson.GetBytes(retriedCompleted, "type").String())
  203. require.Equal(t, "resp_second", gjson.GetBytes(retriedCompleted, "response.id").String())
  204. _ = clientConn.Close(websocket.StatusNormalClosure, "done")
  205. select {
  206. case retryPayload := <-failoverCh:
  207. require.NotEmpty(t, retryPayload)
  208. require.False(t, gjson.GetBytes(retryPayload, "previous_response_id").Exists())
  209. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(retryPayload, "model").String())
  210. input := gjson.GetBytes(retryPayload, "input")
  211. require.True(t, input.IsArray())
  212. require.Len(t, input.Array(), 4)
  213. require.Contains(t, input.Raw, "first")
  214. require.Contains(t, input.Raw, "first-ok")
  215. require.Contains(t, input.Raw, "second")
  216. require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`))
  217. require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`))
  218. require.Equal(t, "client-session", gjson.GetBytes(retryPayload, "client_metadata.session_id").String())
  219. require.Equal(t, "client-thread", gjson.GetBytes(retryPayload, "client_metadata.thread_id").String())
  220. case <-time.After(5 * time.Second):
  221. t.Fatal("timed out waiting for current-turn failover")
  222. }
  223. select {
  224. case proxyErr := <-serverErrCh:
  225. require.NoError(t, proxyErr)
  226. case <-time.After(5 * time.Second):
  227. t.Fatal("timed out waiting for replacement-account completion")
  228. }
  229. require.Len(t, upstream.bodies, 3)
  230. require.Contains(t, string(upstream.bodies[0]), "first")
  231. require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String())
  232. require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String())
  233. require.NotContains(t, string(upstream.bodies[2]), "previous_response_id")
  234. require.Contains(t, string(upstream.bodies[2]), "second")
  235. require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String())
  236. require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String())
  237. require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader))
  238. }