openai_mode1_v3_ws_test.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. coderws "github.com/coder/websocket"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // Inject the loopback CA at the HTTP client boundary. The production path
  16. // chooses standard TLS (no mode1 profile); certificates are still verified.
  17. type mode1V3StandardTestDialer struct {
  18. target string
  19. client *http.Client
  20. protected chan bool
  21. }
  22. func (d *mode1V3StandardTestDialer) Dial(ctx context.Context, _ string, headers http.Header, _ string) (openAIWSClientConn, int, http.Header, error) {
  23. _, protected := ctx.Value(openAIWSTLSContextKey{}).(openAIWSTLSConfig)
  24. d.protected <- protected
  25. conn, response, err := coderws.Dial(ctx, d.target, &coderws.DialOptions{HTTPClient: d.client, HTTPHeader: headers})
  26. if err != nil {
  27. return nil, 0, nil, err
  28. }
  29. conn.SetReadLimit(openAIWSMessageReadLimitBytes)
  30. return &coderOpenAIWSClientConn{conn: conn}, response.StatusCode, response.Header, nil
  31. }
  32. func TestMode1V3StandardWSSPoolSeparatesConversations(t *testing.T) {
  33. server, _, upgrades := newMode1WSTestEndpoint(t)
  34. dialer := &mode1V3StandardTestDialer{target: "wss" + strings.TrimPrefix(server.URL, "https"), client: server.Client(), protected: make(chan bool, 4)}
  35. cfg := &config.Config{}
  36. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2
  37. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2
  38. pool := newOpenAIWSConnPool(cfg)
  39. defer pool.Close()
  40. pool.setClientDialerForTest(dialer)
  41. a := mode1WSAccount(71)
  42. mode1Marker(a)["policy_version"] = 3
  43. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  44. defer cancel()
  45. request := openAIWSAcquireRequest{Account: a, WSURL: dialer.target, Headers: http.Header{"Session-Id": {"first"}, "Thread-Id": {"first"}}}
  46. first, err := pool.Acquire(ctx, request)
  47. require.NoError(t, err)
  48. requireMode1WSEcho(t, ctx, first)
  49. first.Release()
  50. request.Headers = http.Header{"Session-Id": {"second"}, "Thread-Id": {"second"}}
  51. second, err := pool.Acquire(ctx, request)
  52. require.NoError(t, err)
  53. requireMode1WSEcho(t, ctx, second)
  54. require.NotEqual(t, first.ConnID(), second.ConnID())
  55. second.Release()
  56. require.False(t, <-dialer.protected)
  57. require.False(t, <-dialer.protected)
  58. require.EqualValues(t, 2, upgrades.Load())
  59. }
  60. func TestMode1V3WSPassthroughAcceptsCompatibilityFrames(t *testing.T) {
  61. gin.SetMode(gin.TestMode)
  62. upgrades := &atomic.Int32{}
  63. upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  64. conn, err := coderws.Accept(w, r, nil)
  65. if err != nil {
  66. return
  67. }
  68. upgrades.Add(1)
  69. defer conn.CloseNow()
  70. for {
  71. kind, payload, err := conn.Read(r.Context())
  72. if err != nil {
  73. return
  74. }
  75. if err := conn.Write(r.Context(), kind, payload); err != nil {
  76. return
  77. }
  78. if err := conn.Write(r.Context(), coderws.MessageText, []byte(`{"type":"response.completed","response":{"id":"resp_local","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}]}}`)); err != nil {
  79. return
  80. }
  81. }
  82. }))
  83. defer upstream.Close()
  84. dialer := &mode1V3StandardTestDialer{target: "wss" + strings.TrimPrefix(upstream.URL, "https"), client: upstream.Client(), protected: make(chan bool, 4)}
  85. cfg := passthroughLifecycleConfig()
  86. cfg.Gateway.TLSFingerprint.Enabled = false
  87. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  88. a := mode1WSAccount(72)
  89. mode1Marker(a)["policy_version"] = 3
  90. a.Credentials = map[string]any{"chatgpt_account_id": "00000000-0000-4000-8000-000000000072"}
  91. a.Extra["openai_oauth_responses_websockets_v2_mode"] = OpenAIWSIngressModePassthrough
  92. svc := newPassthroughLifecycleService(cfg, newStagedPassthroughConn())
  93. svc.openaiWSPassthroughDialer = dialer
  94. svc.pluginManager = &PluginManager{} // configured manager must not block
  95. ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
  96. defer cancel()
  97. server, serverErr := startPassthroughLifecycleServer(t, ctx, svc, a)
  98. defer server.Close()
  99. first := `{"type":"response.create","model":"gpt-5.2","input":"Return OK","max_output_tokens":100,"reasoning":{"effort":"high"}}`
  100. client := dialPassthroughLifecycleClientWithPayload(t, server, first)
  101. defer client.CloseNow()
  102. _, output, err := client.Read(ctx)
  103. require.NoError(t, err)
  104. require.NoError(t, validateMode1RequestIntegrityForAccount(a, []byte(first), output))
  105. require.Contains(t, string(output), "Return OK")
  106. require.False(t, <-dialer.protected)
  107. _, terminal, err := client.Read(ctx)
  108. require.NoError(t, err)
  109. require.Contains(t, string(terminal), "response.completed")
  110. second := `{"type":"response.create","model":"gpt-5.2","input":[{"type":"function_call","call_id":"call_123","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_123","output":"answer"}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"reasoning":{"effort":"high"}}`
  111. require.NoError(t, client.Write(ctx, coderws.MessageText, []byte(second)))
  112. _, output, err = client.Read(ctx)
  113. require.NoError(t, err)
  114. require.NoError(t, validateMode1RequestIntegrityForAccount(a, []byte(second), output))
  115. require.Contains(t, string(output), "answer")
  116. _, terminal, err = client.Read(ctx)
  117. require.NoError(t, err)
  118. require.Contains(t, string(terminal), "response.completed")
  119. require.NoError(t, client.Close(coderws.StatusNormalClosure, "done"))
  120. select {
  121. case <-serverErr:
  122. case <-ctx.Done():
  123. t.Fatal("passthrough did not close")
  124. }
  125. require.EqualValues(t, 1, upgrades.Load())
  126. }