openai_ws_v2_passthrough_cyber_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. package handler
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/Wei-Shaw/sub2api/internal/testutil"
  13. coderws "github.com/coder/websocket"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. type openAIWSPassthroughHandlerHarness struct {
  19. clientConn *coderws.Conn
  20. handlerDone <-chan struct{}
  21. moderationRepo *contentModerationHandlerTestRepo
  22. gatewayCache service.GatewayCache
  23. apiKey *service.APIKey
  24. }
  25. func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness {
  26. t.Helper()
  27. gatewayCache := testutil.NewRedisGatewayCache(t)
  28. settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{
  29. service.SettingKeyRiskControlEnabled: "true",
  30. service.SettingKeyCyberSessionBlockEnabled: "true",
  31. service.SettingKeyCyberSessionBlockTTLSeconds: "60",
  32. }}
  33. moderationRepo := &contentModerationHandlerTestRepo{}
  34. moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil)
  35. settingSvc := service.NewSettingService(settingRepo, nil)
  36. groupID := int64(4301)
  37. account := service.Account{
  38. ID: 9951,
  39. Name: "openai-ws-passthrough-cyber",
  40. Platform: service.PlatformOpenAI,
  41. Type: service.AccountTypeAPIKey,
  42. Status: service.StatusActive,
  43. Schedulable: true,
  44. Concurrency: 1,
  45. Credentials: map[string]any{"api_key": "sk-test", "base_url": upstreamURL},
  46. Extra: map[string]any{
  47. "openai_apikey_responses_websockets_v2_enabled": true,
  48. "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
  49. },
  50. }
  51. cfg := &config.Config{}
  52. cfg.RunMode = config.RunModeSimple
  53. cfg.Default.RateMultiplier = 1
  54. cfg.Security.URLAllowlist.Enabled = false
  55. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  56. cfg.Gateway.OpenAIWS.Enabled = true
  57. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  58. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  59. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  60. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  61. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
  62. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  63. cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 3
  64. accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
  65. usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 2)}
  66. billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
  67. gatewaySvc := service.NewOpenAIGatewayService(
  68. accountRepo, usageRepo, nil, nil, nil, nil, gatewayCache, cfg, nil, nil,
  69. service.NewBillingService(cfg, nil), nil, billingCacheSvc, nil, &service.DeferredService{},
  70. nil, nil, nil, nil, nil, settingSvc, nil,
  71. )
  72. concurrencyCache := &concurrencyCacheMock{
  73. acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
  74. acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
  75. }
  76. h := &OpenAIGatewayHandler{
  77. gatewayService: gatewaySvc,
  78. billingCacheService: billingCacheSvc,
  79. apiKeyService: &service.APIKeyService{},
  80. contentModerationService: moderationSvc,
  81. concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(concurrencyCache), SSEPingFormatNone, time.Second),
  82. }
  83. apiKey := &service.APIKey{
  84. ID: 1851,
  85. Name: "ws-cyber-key",
  86. Key: "sk-handler-cyber-test",
  87. GroupID: &groupID,
  88. User: &service.User{ID: 1751, Status: service.StatusActive},
  89. }
  90. handlerDone := make(chan struct{})
  91. router := gin.New()
  92. router.Use(func(c *gin.Context) {
  93. c.Set(string(middleware.ContextKeyAPIKey), apiKey)
  94. c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
  95. c.Next()
  96. })
  97. router.GET("/openai/v1/responses", func(c *gin.Context) {
  98. h.ResponsesWebSocket(c)
  99. close(handlerDone)
  100. })
  101. handlerServer := httptest.NewServer(router)
  102. t.Cleanup(handlerServer.Close)
  103. dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
  104. clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(handlerServer.URL, "http")+"/openai/v1/responses", nil)
  105. cancelDial()
  106. require.NoError(t, err)
  107. t.Cleanup(func() { _ = clientConn.CloseNow() })
  108. return &openAIWSPassthroughHandlerHarness{
  109. clientConn: clientConn,
  110. handlerDone: handlerDone,
  111. moderationRepo: moderationRepo,
  112. gatewayCache: gatewayCache,
  113. apiKey: apiKey,
  114. }
  115. }
  116. func TestOpenAIResponsesWebSocketV2PassthroughCyberMarkIsConsumedAfterTurn(t *testing.T) {
  117. gin.SetMode(gin.TestMode)
  118. upstreamDone := make(chan struct{})
  119. secondUpstreamFrame := make(chan []byte, 1)
  120. upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  121. defer close(upstreamDone)
  122. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  123. require.NoError(t, err)
  124. defer func() { _ = conn.CloseNow() }()
  125. readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
  126. _, _, err = conn.Read(readCtx)
  127. cancelRead()
  128. require.NoError(t, err)
  129. failed := []byte(`{"type":"response.failed","response":{"id":"resp_cyber_handler","model":"gpt-5.1","error":{"code":"cyber_policy","message":"blocked by upstream policy"},"usage":{"input_tokens":11,"output_tokens":3}}}`)
  130. writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
  131. err = conn.Write(writeCtx, coderws.MessageText, failed)
  132. cancelWrite()
  133. require.NoError(t, err)
  134. readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
  135. _, second, err := conn.Read(readCtx)
  136. cancelRead()
  137. if err != nil {
  138. return
  139. }
  140. secondUpstreamFrame <- append([]byte(nil), second...)
  141. completed := []byte(`{"type":"response.completed","response":{"id":"resp_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  142. writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
  143. err = conn.Write(writeCtx, coderws.MessageText, completed)
  144. cancelWrite()
  145. require.NoError(t, err)
  146. }))
  147. defer upstreamServer.Close()
  148. harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
  149. requestPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"test"}`
  150. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  151. err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(requestPayload))
  152. cancelWrite()
  153. require.NoError(t, err)
  154. readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
  155. _, event, err := harness.clientConn.Read(readCtx)
  156. cancelRead()
  157. require.NoError(t, err)
  158. require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String())
  159. require.Eventually(t, func() bool {
  160. logs := harness.moderationRepo.logSnapshot()
  161. return len(logs) == 1 && logs[0].Action == service.ContentModerationActionCyberPolicy &&
  162. strings.Contains(logs[0].Error, "upstream_usage=in:11,out:3")
  163. }, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must call recordCyberPolicyIfMarked and write the risk-control event")
  164. keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  165. keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(requestPayload))
  166. blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(requestPayload))
  167. require.NotEmpty(t, blockKey)
  168. store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
  169. require.True(t, ok)
  170. require.Eventually(t, func() bool {
  171. matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
  172. return findErr == nil && matched == blockKey
  173. }, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must write the cyber session block table")
  174. writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
  175. err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"follow-up"}`))
  176. cancelWrite()
  177. require.NoError(t, err)
  178. readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
  179. _, _, err = harness.clientConn.Read(readCtx)
  180. cancelRead()
  181. var closeErr coderws.CloseError
  182. require.ErrorAs(t, err, &closeErr)
  183. require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
  184. // closeOpenAIClientWS caps close reasons at 120 bytes; passthrough must expose
  185. // the same client-visible prefix rather than dropping the close frame.
  186. require.Equal(t, "该会话已被网络安全策略屏蔽,请开启新会话 / This session is blocked by cyber-security policy, please ", closeErr.Reason)
  187. select {
  188. case <-harness.handlerDone:
  189. case <-time.After(3 * time.Second):
  190. t.Fatal("websocket handler did not exit")
  191. }
  192. select {
  193. case <-upstreamDone:
  194. case <-time.After(3 * time.Second):
  195. t.Fatal("upstream websocket did not exit")
  196. }
  197. select {
  198. case second := <-secondUpstreamFrame:
  199. t.Fatalf("blocked follow-up reached upstream: %s", second)
  200. default:
  201. }
  202. }
  203. func TestOpenAIResponsesWebSocketV2PassthroughNonCyberTurnAllowsFollowup(t *testing.T) {
  204. gin.SetMode(gin.TestMode)
  205. upstreamDone := make(chan struct{})
  206. secondUpstreamFrame := make(chan []byte, 1)
  207. upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  208. defer close(upstreamDone)
  209. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  210. require.NoError(t, err)
  211. defer func() { _ = conn.CloseNow() }()
  212. readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
  213. _, _, err = conn.Read(readCtx)
  214. cancelRead()
  215. require.NoError(t, err)
  216. firstCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":1}}}`)
  217. writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
  218. err = conn.Write(writeCtx, coderws.MessageText, firstCompleted)
  219. cancelWrite()
  220. require.NoError(t, err)
  221. readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
  222. _, second, err := conn.Read(readCtx)
  223. cancelRead()
  224. require.NoError(t, err)
  225. secondUpstreamFrame <- append([]byte(nil), second...)
  226. secondCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":3,"output_tokens":1}}}`)
  227. writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
  228. err = conn.Write(writeCtx, coderws.MessageText, secondCompleted)
  229. cancelWrite()
  230. require.NoError(t, err)
  231. readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
  232. _, _, _ = conn.Read(readCtx)
  233. cancelRead()
  234. }))
  235. defer upstreamServer.Close()
  236. harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
  237. firstPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"first"}`
  238. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  239. err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(firstPayload))
  240. cancelWrite()
  241. require.NoError(t, err)
  242. readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
  243. _, firstEvent, err := harness.clientConn.Read(readCtx)
  244. cancelRead()
  245. require.NoError(t, err)
  246. require.Equal(t, "resp_non_cyber_handler_turn_1", gjson.GetBytes(firstEvent, "response.id").String())
  247. secondPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"follow-up"}`
  248. writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
  249. err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(secondPayload))
  250. cancelWrite()
  251. require.NoError(t, err)
  252. readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
  253. _, secondEvent, err := harness.clientConn.Read(readCtx)
  254. cancelRead()
  255. require.NoError(t, err)
  256. require.Equal(t, "resp_non_cyber_handler_turn_2", gjson.GetBytes(secondEvent, "response.id").String())
  257. require.Empty(t, harness.moderationRepo.logSnapshot())
  258. keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  259. keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(firstPayload))
  260. blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(firstPayload))
  261. require.NotEmpty(t, blockKey)
  262. store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
  263. require.True(t, ok)
  264. matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
  265. require.NoError(t, findErr)
  266. require.Empty(t, matched)
  267. require.NoError(t, harness.clientConn.Close(coderws.StatusNormalClosure, "done"))
  268. select {
  269. case <-harness.handlerDone:
  270. case <-time.After(3 * time.Second):
  271. t.Fatal("non-cyber websocket handler did not exit")
  272. }
  273. select {
  274. case <-upstreamDone:
  275. case <-time.After(3 * time.Second):
  276. t.Fatal("non-cyber upstream websocket did not exit")
  277. }
  278. select {
  279. case second := <-secondUpstreamFrame:
  280. require.JSONEq(t, secondPayload, string(second))
  281. default:
  282. t.Fatal("non-cyber follow-up did not reach upstream")
  283. }
  284. }