openai_ws_http_bridge_isolation_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  15. coderws "github.com/coder/websocket"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. "github.com/tidwall/gjson"
  19. )
  20. type httpBridgeIsolationBody struct {
  21. ctx context.Context
  22. prefix *bytes.Reader
  23. suffix *bytes.Reader
  24. release <-chan struct{}
  25. closed chan struct{}
  26. once sync.Once
  27. }
  28. func (b *httpBridgeIsolationBody) Read(p []byte) (int, error) {
  29. if b.prefix.Len() > 0 {
  30. return b.prefix.Read(p)
  31. }
  32. select {
  33. case <-b.release:
  34. return b.suffix.Read(p)
  35. case <-b.closed:
  36. return 0, io.EOF
  37. case <-b.ctx.Done():
  38. return 0, b.ctx.Err()
  39. }
  40. }
  41. func (b *httpBridgeIsolationBody) Close() error {
  42. b.once.Do(func() { close(b.closed) })
  43. return nil
  44. }
  45. type httpBridgeIsolationRequest struct {
  46. input []string
  47. state string
  48. }
  49. type httpBridgeIsolationUpstream struct {
  50. ctx context.Context
  51. mu sync.Mutex
  52. requests []httpBridgeIsolationRequest
  53. firstRelease chan struct{}
  54. }
  55. func (u *httpBridgeIsolationUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  56. body, err := io.ReadAll(req.Body)
  57. if err != nil {
  58. return nil, err
  59. }
  60. _ = req.Body.Close()
  61. input := gjson.GetBytes(body, "input")
  62. var texts []string
  63. if input.Type == gjson.String {
  64. texts = append(texts, input.String())
  65. } else {
  66. for _, item := range input.Array() {
  67. if item.Type == gjson.String {
  68. texts = append(texts, item.String())
  69. continue
  70. }
  71. if item.Get("type").String() == "function_call" {
  72. texts = append(texts, item.Get("call_id").String())
  73. continue
  74. }
  75. if item.Get("type").String() == "function_call_output" {
  76. texts = append(texts, item.Get("output").String())
  77. continue
  78. }
  79. content := item.Get("content")
  80. if content.Type == gjson.String {
  81. texts = append(texts, content.String())
  82. } else {
  83. for _, part := range content.Array() {
  84. if text := part.Get("text"); text.Exists() {
  85. texts = append(texts, text.String())
  86. }
  87. }
  88. }
  89. }
  90. }
  91. if len(texts) == 0 {
  92. return nil, fmt.Errorf("missing bridge input")
  93. }
  94. u.mu.Lock()
  95. u.requests = append(u.requests, httpBridgeIsolationRequest{input: texts, state: req.Header.Get(openAIWSTurnStateHeader)})
  96. u.mu.Unlock()
  97. id := texts[0]
  98. turn := 1
  99. if input.IsArray() {
  100. turn = 2
  101. }
  102. responseID := fmt.Sprintf("resp_%s_%d", id, turn)
  103. prefix := fmt.Sprintf("data: {\"type\":\"response.created\",\"response\":{\"id\":%q,\"status\":\"in_progress\"}}\n\n", responseID)
  104. if turn == 1 {
  105. prefix += "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"rs_test\",\"type\":\"reasoning\",\"encrypted_content\":\"opaque\"}}\n\n"
  106. }
  107. output := "[]"
  108. if turn == 1 {
  109. output = fmt.Sprintf(`[{"type":"function_call","id":%q,"call_id":%q,"name":"lookup","arguments":"{}","status":"completed"}]`, "fc_"+id, "call_"+id)
  110. }
  111. suffix := fmt.Sprintf("data: {\"type\":\"response.completed\",\"response\":{\"id\":%q,\"status\":\"completed\",\"output\":%s,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", responseID, output)
  112. headers := http.Header{"Content-Type": []string{"text/event-stream"}}
  113. headers.Set(openAIWSTurnStateHeader, "turn-"+id)
  114. responseBody := io.NopCloser(strings.NewReader(prefix + suffix))
  115. if turn == 1 {
  116. responseBody = &httpBridgeIsolationBody{ctx: u.ctx, prefix: bytes.NewReader([]byte(prefix)), suffix: bytes.NewReader([]byte(suffix)), release: u.firstRelease, closed: make(chan struct{})}
  117. }
  118. return &http.Response{StatusCode: http.StatusOK, Header: headers, Body: responseBody}, nil
  119. }
  120. func (u *httpBridgeIsolationUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  121. return u.Do(req, proxyURL, accountID, concurrency)
  122. }
  123. func TestOpenAIWSHTTPBridgeSessionIsolationAcrossSameSessionHash(t *testing.T) {
  124. gin.SetMode(gin.TestMode)
  125. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  126. defer cancel()
  127. cfg := &config.Config{}
  128. cfg.Gateway.OpenAIWS.Enabled = true
  129. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  130. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  131. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  132. cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeHTTPBridge
  133. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  134. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 5
  135. upstream := &httpBridgeIsolationUpstream{ctx: ctx, firstRelease: make(chan struct{})}
  136. stateStore := NewOpenAIWSStateStore(nil)
  137. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), openaiWSStateStore: stateStore}
  138. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  139. Credentials: map[string]any{"access_token": "test-token"},
  140. Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge},
  141. Concurrency: 2, Status: StatusActive, Schedulable: true}
  142. groupID := int64(7)
  143. newContext := func(r *http.Request) *gin.Context {
  144. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  145. c.Request = r.Clone(ctx)
  146. c.Request.Header.Set("session-id", "shared-session")
  147. c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
  148. return c
  149. }
  150. seedHash := svc.GenerateSessionHash(newContext(httptest.NewRequest(http.MethodGet, "/v1/responses", nil)), nil)
  151. stateStore.BindSessionTurnState(groupID, seedHash, "sentinel-turn-state", time.Hour)
  152. stateStore.BindSessionConn(groupID, seedHash, "sentinel-native-conn", time.Hour)
  153. serverResults := make(chan error, 2)
  154. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  155. conn, err := coderws.Accept(w, r, nil)
  156. if err != nil {
  157. serverResults <- err
  158. return
  159. }
  160. defer func() { _ = conn.CloseNow() }()
  161. _, firstMessage, err := conn.Read(ctx)
  162. if err != nil {
  163. serverResults <- err
  164. return
  165. }
  166. c := newContext(r)
  167. // Match the handler-owned registration and nested forwarding call.
  168. preemptCtx, cleanup, _ := svc.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, firstMessage)
  169. defer cleanup()
  170. hooks := &OpenAIWSIngressHooks{ClientLifecycleContext: ctx}
  171. serverResults <- svc.ProxyResponsesWebSocketFromClient(preemptCtx, c, conn, account, "test-token", firstMessage, hooks)
  172. }))
  173. var releaseOnce sync.Once
  174. release := func() { releaseOnce.Do(func() { close(upstream.firstRelease) }) }
  175. defer func() {
  176. release()
  177. cancel()
  178. server.Close()
  179. }()
  180. readEvent := func(conn *coderws.Conn, wantType string) []byte {
  181. t.Helper()
  182. _, event, err := conn.Read(ctx)
  183. require.NoError(t, err)
  184. require.Equal(t, wantType, gjson.GetBytes(event, "type").String(), "event: %s", event)
  185. return event
  186. }
  187. clients := make([]*coderws.Conn, 0, 2)
  188. for _, id := range []string{"alpha", "beta"} {
  189. conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
  190. require.NoError(t, err)
  191. defer func() { _ = conn.CloseNow() }()
  192. clients = append(clients, conn)
  193. payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","prompt_cache_key":%q,"client_metadata":{"thread_id":%q},"input":%q}`, id+"-cache", id+"-thread", id)
  194. require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(payload)))
  195. readEvent(conn, "response.created")
  196. readEvent(conn, "response.output_item.added")
  197. }
  198. // Both HTTP streams are now in flight. Neither may replace the other.
  199. release()
  200. for i, id := range []string{"alpha", "beta"} {
  201. event := readEvent(clients[i], "response.completed")
  202. require.Equal(t, "resp_"+id+"_1", gjson.GetBytes(event, "response.id").String())
  203. }
  204. for i, id := range []string{"alpha", "beta"} {
  205. payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","previous_response_id":%q,"input":[{"type":"function_call_output","call_id":%q,"output":%q}]}`, "resp_"+id+"_1", "call_"+id, id+"-result")
  206. require.NoError(t, clients[i].Write(ctx, coderws.MessageText, []byte(payload)))
  207. readEvent(clients[i], "response.created")
  208. event := readEvent(clients[i], "response.completed")
  209. require.Equal(t, "resp_"+id+"_2", gjson.GetBytes(event, "response.id").String())
  210. require.NoError(t, clients[i].Close(coderws.StatusNormalClosure, "done"))
  211. }
  212. for range clients {
  213. select {
  214. case err := <-serverResults:
  215. require.NoError(t, err)
  216. case <-ctx.Done():
  217. t.Fatal(ctx.Err())
  218. }
  219. }
  220. upstream.mu.Lock()
  221. requests := append([]httpBridgeIsolationRequest(nil), upstream.requests...)
  222. upstream.mu.Unlock()
  223. require.ElementsMatch(t, []httpBridgeIsolationRequest{
  224. {input: []string{"alpha"}},
  225. {input: []string{"beta"}},
  226. {input: []string{"alpha", "call_alpha", "alpha-result"}, state: "turn-alpha"},
  227. {input: []string{"beta", "call_beta", "beta-result"}, state: "turn-beta"},
  228. }, requests)
  229. gotState, ok := stateStore.GetSessionTurnState(groupID, seedHash)
  230. require.True(t, ok)
  231. require.Equal(t, "sentinel-turn-state", gotState)
  232. gotConn, ok := stateStore.GetSessionConn(groupID, seedHash)
  233. require.True(t, ok)
  234. require.Equal(t, "sentinel-native-conn", gotConn)
  235. }