openai_ws_ingress_capacity_shed_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. coderws "github.com/coder/websocket"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. // openAIWSIngressCapacityShedRepo 补齐 SetError,避免非容量类错误(如
  16. // workspace_suspended)走到账号状态副作用时打空指针。
  17. type openAIWSIngressCapacityShedRepo struct {
  18. stubOpenAIAccountRepo
  19. }
  20. func (r *openAIWSIngressCapacityShedRepo) SetError(context.Context, int64, string) error { return nil }
  21. func (r *openAIWSIngressCapacityShedRepo) SetRateLimited(context.Context, int64, time.Time) error {
  22. return nil
  23. }
  24. func (r *openAIWSIngressCapacityShedRepo) UpdateExtra(context.Context, int64, map[string]any) error {
  25. return nil
  26. }
  27. // ctx_pool 的 ingress 直写路径把 error / response.failed 交给 WS 客户端前,必须和
  28. // HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge
  29. // (openai_ws_http_bridge.go)两条路径一样,把容量降载码改写为可重试的
  30. // server_error:Codex 按闭集判定,server_is_overloaded / slow_down 属致命集,
  31. // 客户端会打印 "Selected model is at capacity" 并直接终止会话而不是退避重试。
  32. //
  33. // 第二个用例锁住改写范围:非容量类错误码必须原样下发,客户端依赖原码各自处理。
  34. func TestProxyResponsesWebSocketFromClient_RewritesCapacityShedCodeForClient(t *testing.T) {
  35. gin.SetMode(gin.TestMode)
  36. tests := []struct {
  37. name string
  38. upstreamEvents [][]byte
  39. wantContains []string
  40. wantAbsent []string
  41. }{
  42. {
  43. name: "capacity_shed_error_and_failed_are_rewritten",
  44. upstreamEvents: [][]byte{
  45. []byte(`{"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}`),
  46. []byte(`{"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`),
  47. },
  48. wantContains: []string{
  49. `"code":"server_error"`,
  50. "Our servers are currently overloaded",
  51. },
  52. wantAbsent: []string{"server_is_overloaded"},
  53. },
  54. {
  55. name: "non_capacity_error_code_is_passed_through",
  56. upstreamEvents: [][]byte{
  57. []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"workspace_suspended","message":"workspace is suspended"}}`),
  58. []byte(`{"type":"response.failed","response":{"id":"resp_suspended","status":"failed","error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`),
  59. },
  60. wantContains: []string{
  61. `"code":"workspace_suspended"`,
  62. "workspace is suspended",
  63. },
  64. wantAbsent: []string{"server_error"},
  65. },
  66. }
  67. for _, tt := range tests {
  68. t.Run(tt.name, func(t *testing.T) {
  69. cfg := newOpenAIWSV2TestConfig()
  70. cfg.Security.URLAllowlist.Enabled = false
  71. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  72. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  73. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  74. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  75. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  76. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  77. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
  78. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  79. events := make([][]byte, 0, len(tt.upstreamEvents))
  80. for _, event := range tt.upstreamEvents {
  81. events = append(events, append([]byte(nil), event...))
  82. }
  83. captureConn := &openAIWSCaptureConn{events: events}
  84. pool := newOpenAIWSConnPool(cfg)
  85. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  86. account := Account{
  87. ID: 5401,
  88. Name: "openai-ingress-capacity-shed",
  89. Platform: PlatformOpenAI,
  90. Type: AccountTypeAPIKey,
  91. Status: StatusActive,
  92. Schedulable: true,
  93. Concurrency: 1,
  94. Credentials: map[string]any{"api_key": "sk-test"},
  95. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  96. }
  97. repo := &openAIWSIngressCapacityShedRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}}
  98. svc := &OpenAIGatewayService{
  99. accountRepo: repo,
  100. rateLimitService: &RateLimitService{accountRepo: repo},
  101. httpUpstream: &httpUpstreamRecorder{},
  102. cache: &stubGatewayCache{},
  103. cfg: cfg,
  104. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  105. toolCorrector: NewCodexToolCorrector(),
  106. openaiWSPool: pool,
  107. }
  108. serverDone := make(chan struct{})
  109. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  110. defer close(serverDone)
  111. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  112. if err != nil {
  113. return
  114. }
  115. defer func() { _ = conn.CloseNow() }()
  116. rec := httptest.NewRecorder()
  117. ginCtx, _ := gin.CreateTestContext(rec)
  118. req := r.Clone(r.Context())
  119. req.Header = req.Header.Clone()
  120. req.Header.Set("User-Agent", "unit-test-agent/1.0")
  121. ginCtx.Request = req
  122. readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
  123. msgType, firstMessage, readErr := conn.Read(readCtx)
  124. cancel()
  125. if readErr != nil || (msgType != coderws.MessageText && msgType != coderws.MessageBinary) {
  126. return
  127. }
  128. _ = svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, &account, "sk-test", firstMessage, nil)
  129. }))
  130. defer wsServer.Close()
  131. dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
  132. clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
  133. cancelDial()
  134. require.NoError(t, err)
  135. defer func() { _ = clientConn.CloseNow() }()
  136. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  137. err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
  138. cancelWrite()
  139. require.NoError(t, err)
  140. var frames []string
  141. for len(frames) < len(tt.upstreamEvents) {
  142. readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  143. _, message, readErr := clientConn.Read(readCtx)
  144. cancel()
  145. if readErr != nil {
  146. break
  147. }
  148. frames = append(frames, string(message))
  149. }
  150. // 本轮已终止,主动断开客户端让 ingress 退出 turn 循环。
  151. _ = clientConn.CloseNow()
  152. require.NotEmpty(t, frames, "客户端应至少收到一个下发事件")
  153. joined := strings.Join(frames, "\n")
  154. for _, want := range tt.wantContains {
  155. require.Contains(t, joined, want, "客户端收到的事件:\n%s", joined)
  156. }
  157. for _, absent := range tt.wantAbsent {
  158. require.NotContains(t, joined, absent, "客户端收到的事件:\n%s", joined)
  159. }
  160. select {
  161. case <-serverDone:
  162. case <-time.After(5 * time.Second):
  163. t.Fatal("等待 ingress websocket 结束超时")
  164. }
  165. })
  166. }
  167. }
  168. func TestProxyResponsesWebSocketFromClient_MarksCyberPolicyBeforeEarlyReturn(t *testing.T) {
  169. gin.SetMode(gin.TestMode)
  170. tests := []struct {
  171. name string
  172. upstreamEvent []byte
  173. wantFailover bool
  174. wantClientMsg bool
  175. wantInput int
  176. wantOutput int
  177. }{
  178. {
  179. name: "error_before_rate_limit_failover",
  180. upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`),
  181. wantFailover: true,
  182. wantInput: 5,
  183. wantOutput: 1,
  184. },
  185. {
  186. name: "response_failed_terminal",
  187. upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`),
  188. wantClientMsg: true,
  189. wantInput: 9,
  190. wantOutput: 2,
  191. },
  192. }
  193. for _, tt := range tests {
  194. t.Run(tt.name, func(t *testing.T) {
  195. cfg := newOpenAIWSV2TestConfig()
  196. cfg.Security.URLAllowlist.Enabled = false
  197. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  198. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  199. cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
  200. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  201. captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}}
  202. pool := newOpenAIWSConnPool(cfg)
  203. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  204. svc := &OpenAIGatewayService{
  205. cfg: cfg,
  206. httpUpstream: &httpUpstreamRecorder{},
  207. cache: &stubGatewayCache{},
  208. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  209. toolCorrector: NewCodexToolCorrector(),
  210. openaiWSPool: pool,
  211. }
  212. account := &Account{
  213. ID: 5402, Name: "openai-ingress-cyber", Platform: PlatformOpenAI,
  214. Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1,
  215. Credentials: map[string]any{"api_key": "sk-test"},
  216. Extra: map[string]any{
  217. "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool,
  218. },
  219. }
  220. markCh := make(chan *CyberPolicyMark, 1)
  221. serverErrCh := make(chan error, 1)
  222. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  223. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  224. if err != nil {
  225. serverErrCh <- err
  226. return
  227. }
  228. defer func() { _ = conn.CloseNow() }()
  229. readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
  230. _, firstMessage, err := conn.Read(readCtx)
  231. cancelRead()
  232. if err != nil {
  233. serverErrCh <- err
  234. return
  235. }
  236. recorder := httptest.NewRecorder()
  237. ginCtx, _ := gin.CreateTestContext(recorder)
  238. ginCtx.Request = r.Clone(r.Context())
  239. hooks := &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) {
  240. markCh <- GetOpsCyberPolicy(ginCtx)
  241. }}
  242. serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks)
  243. }))
  244. defer wsServer.Close()
  245. dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
  246. clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
  247. cancelDial()
  248. require.NoError(t, err)
  249. defer func() { _ = clientConn.CloseNow() }()
  250. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  251. err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
  252. cancelWrite()
  253. require.NoError(t, err)
  254. if tt.wantClientMsg {
  255. readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
  256. _, message, readErr := clientConn.Read(readCtx)
  257. cancelRead()
  258. require.NoError(t, readErr)
  259. require.Equal(t, "response.failed", gjson.GetBytes(message, "type").String())
  260. }
  261. select {
  262. case mark := <-markCh:
  263. require.NotNil(t, mark)
  264. require.Equal(t, "cyber_policy", mark.Code)
  265. require.Equal(t, tt.wantInput, mark.UpstreamInTok)
  266. require.Equal(t, tt.wantOutput, mark.UpstreamOutTok)
  267. case <-time.After(3 * time.Second):
  268. t.Fatal("AfterTurn did not observe the cyber mark")
  269. }
  270. _ = clientConn.CloseNow()
  271. select {
  272. case serverErr := <-serverErrCh:
  273. var failoverErr *UpstreamFailoverError
  274. require.Equal(t, tt.wantFailover, errors.As(serverErr, &failoverErr))
  275. case <-time.After(5 * time.Second):
  276. t.Fatal("waiting for ingress websocket shutdown timed out")
  277. }
  278. })
  279. }
  280. }