| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- package handler
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/Wei-Shaw/sub2api/internal/testutil"
- coderws "github.com/coder/websocket"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- type openAIWSPassthroughHandlerHarness struct {
- clientConn *coderws.Conn
- handlerDone <-chan struct{}
- moderationRepo *contentModerationHandlerTestRepo
- gatewayCache service.GatewayCache
- apiKey *service.APIKey
- }
- func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness {
- t.Helper()
- gatewayCache := testutil.NewRedisGatewayCache(t)
- settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{
- service.SettingKeyRiskControlEnabled: "true",
- service.SettingKeyCyberSessionBlockEnabled: "true",
- service.SettingKeyCyberSessionBlockTTLSeconds: "60",
- }}
- moderationRepo := &contentModerationHandlerTestRepo{}
- moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil)
- settingSvc := service.NewSettingService(settingRepo, nil)
- groupID := int64(4301)
- account := service.Account{
- ID: 9951,
- Name: "openai-ws-passthrough-cyber",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Status: service.StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test", "base_url": upstreamURL},
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
- },
- }
- cfg := &config.Config{}
- cfg.RunMode = config.RunModeSimple
- cfg.Default.RateMultiplier = 1
- cfg.Security.URLAllowlist.Enabled = false
- cfg.Security.URLAllowlist.AllowInsecureHTTP = true
- cfg.Gateway.OpenAIWS.Enabled = true
- cfg.Gateway.OpenAIWS.APIKeyEnabled = true
- cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
- cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
- cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 3
- accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
- usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 2)}
- billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
- gatewaySvc := service.NewOpenAIGatewayService(
- accountRepo, usageRepo, nil, nil, nil, nil, gatewayCache, cfg, nil, nil,
- service.NewBillingService(cfg, nil), nil, billingCacheSvc, nil, &service.DeferredService{},
- nil, nil, nil, nil, nil, settingSvc, nil,
- )
- concurrencyCache := &concurrencyCacheMock{
- acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
- acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
- }
- h := &OpenAIGatewayHandler{
- gatewayService: gatewaySvc,
- billingCacheService: billingCacheSvc,
- apiKeyService: &service.APIKeyService{},
- contentModerationService: moderationSvc,
- concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(concurrencyCache), SSEPingFormatNone, time.Second),
- }
- apiKey := &service.APIKey{
- ID: 1851,
- Name: "ws-cyber-key",
- Key: "sk-handler-cyber-test",
- GroupID: &groupID,
- User: &service.User{ID: 1751, Status: service.StatusActive},
- }
- handlerDone := make(chan struct{})
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(middleware.ContextKeyAPIKey), apiKey)
- c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
- c.Next()
- })
- router.GET("/openai/v1/responses", func(c *gin.Context) {
- h.ResponsesWebSocket(c)
- close(handlerDone)
- })
- handlerServer := httptest.NewServer(router)
- t.Cleanup(handlerServer.Close)
- dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
- clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(handlerServer.URL, "http")+"/openai/v1/responses", nil)
- cancelDial()
- require.NoError(t, err)
- t.Cleanup(func() { _ = clientConn.CloseNow() })
- return &openAIWSPassthroughHandlerHarness{
- clientConn: clientConn,
- handlerDone: handlerDone,
- moderationRepo: moderationRepo,
- gatewayCache: gatewayCache,
- apiKey: apiKey,
- }
- }
- func TestOpenAIResponsesWebSocketV2PassthroughCyberMarkIsConsumedAfterTurn(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstreamDone := make(chan struct{})
- secondUpstreamFrame := make(chan []byte, 1)
- upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- defer close(upstreamDone)
- conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
- require.NoError(t, err)
- defer func() { _ = conn.CloseNow() }()
- readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
- _, _, err = conn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- 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}}}`)
- writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
- err = conn.Write(writeCtx, coderws.MessageText, failed)
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
- _, second, err := conn.Read(readCtx)
- cancelRead()
- if err != nil {
- return
- }
- secondUpstreamFrame <- append([]byte(nil), second...)
- completed := []byte(`{"type":"response.completed","response":{"id":"resp_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
- writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
- err = conn.Write(writeCtx, coderws.MessageText, completed)
- cancelWrite()
- require.NoError(t, err)
- }))
- defer upstreamServer.Close()
- harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
- requestPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"test"}`
- writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(requestPayload))
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
- _, event, err := harness.clientConn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String())
- require.Eventually(t, func() bool {
- logs := harness.moderationRepo.logSnapshot()
- return len(logs) == 1 && logs[0].Action == service.ContentModerationActionCyberPolicy &&
- strings.Contains(logs[0].Error, "upstream_usage=in:11,out:3")
- }, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must call recordCyberPolicyIfMarked and write the risk-control event")
- keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
- keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(requestPayload))
- blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(requestPayload))
- require.NotEmpty(t, blockKey)
- store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
- require.True(t, ok)
- require.Eventually(t, func() bool {
- matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
- return findErr == nil && matched == blockKey
- }, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must write the cyber session block table")
- writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
- err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"follow-up"}`))
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
- _, _, err = harness.clientConn.Read(readCtx)
- cancelRead()
- var closeErr coderws.CloseError
- require.ErrorAs(t, err, &closeErr)
- require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
- // closeOpenAIClientWS caps close reasons at 120 bytes; passthrough must expose
- // the same client-visible prefix rather than dropping the close frame.
- require.Equal(t, "该会话已被网络安全策略屏蔽,请开启新会话 / This session is blocked by cyber-security policy, please ", closeErr.Reason)
- select {
- case <-harness.handlerDone:
- case <-time.After(3 * time.Second):
- t.Fatal("websocket handler did not exit")
- }
- select {
- case <-upstreamDone:
- case <-time.After(3 * time.Second):
- t.Fatal("upstream websocket did not exit")
- }
- select {
- case second := <-secondUpstreamFrame:
- t.Fatalf("blocked follow-up reached upstream: %s", second)
- default:
- }
- }
- func TestOpenAIResponsesWebSocketV2PassthroughNonCyberTurnAllowsFollowup(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstreamDone := make(chan struct{})
- secondUpstreamFrame := make(chan []byte, 1)
- upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- defer close(upstreamDone)
- conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
- require.NoError(t, err)
- defer func() { _ = conn.CloseNow() }()
- readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
- _, _, err = conn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- firstCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":1}}}`)
- writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
- err = conn.Write(writeCtx, coderws.MessageText, firstCompleted)
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
- _, second, err := conn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- secondUpstreamFrame <- append([]byte(nil), second...)
- secondCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":3,"output_tokens":1}}}`)
- writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
- err = conn.Write(writeCtx, coderws.MessageText, secondCompleted)
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
- _, _, _ = conn.Read(readCtx)
- cancelRead()
- }))
- defer upstreamServer.Close()
- harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
- firstPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"first"}`
- writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(firstPayload))
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
- _, firstEvent, err := harness.clientConn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- require.Equal(t, "resp_non_cyber_handler_turn_1", gjson.GetBytes(firstEvent, "response.id").String())
- secondPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"follow-up"}`
- writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
- err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(secondPayload))
- cancelWrite()
- require.NoError(t, err)
- readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
- _, secondEvent, err := harness.clientConn.Read(readCtx)
- cancelRead()
- require.NoError(t, err)
- require.Equal(t, "resp_non_cyber_handler_turn_2", gjson.GetBytes(secondEvent, "response.id").String())
- require.Empty(t, harness.moderationRepo.logSnapshot())
- keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
- keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(firstPayload))
- blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(firstPayload))
- require.NotEmpty(t, blockKey)
- store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
- require.True(t, ok)
- matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
- require.NoError(t, findErr)
- require.Empty(t, matched)
- require.NoError(t, harness.clientConn.Close(coderws.StatusNormalClosure, "done"))
- select {
- case <-harness.handlerDone:
- case <-time.After(3 * time.Second):
- t.Fatal("non-cyber websocket handler did not exit")
- }
- select {
- case <-upstreamDone:
- case <-time.After(3 * time.Second):
- t.Fatal("non-cyber upstream websocket did not exit")
- }
- select {
- case second := <-secondUpstreamFrame:
- require.JSONEq(t, secondPayload, string(second))
- default:
- t.Fatal("non-cyber follow-up did not reach upstream")
- }
- }
|