openai_sse_concatenated_json_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. package service
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func TestOpenAIStreamingRepairsConcatenatedJSONDocumentsInSingleDataLine(t *testing.T) {
  17. testOpenAIStreamingRepairsConcatenatedJSONDocuments(t, false, 0)
  18. }
  19. func TestOpenAIStreamingAsyncScannerRepairsConcatenatedJSONDocumentsInSingleDataLine(t *testing.T) {
  20. testOpenAIStreamingRepairsConcatenatedJSONDocuments(t, false, 30)
  21. }
  22. func TestOpenAIStreamingPassthroughRepairsConcatenatedJSONDocumentsInSingleDataLine(t *testing.T) {
  23. testOpenAIStreamingRepairsConcatenatedJSONDocuments(t, true, 0)
  24. }
  25. func TestOpenAIWSv2StreamingRepairsConcatenatedJSONDocumentsInSingleMessage(t *testing.T) {
  26. gin.SetMode(gin.TestMode)
  27. largeInProgress, outputItemAdded, completed := openAIConcatenatedJSONTestEvents(t)
  28. captureConn := &openAIWSCaptureConn{events: [][]byte{
  29. []byte(largeInProgress + outputItemAdded),
  30. []byte(completed),
  31. }}
  32. cfg := &config.Config{}
  33. cfg.Security.URLAllowlist.Enabled = false
  34. cfg.Gateway.OpenAIWS.Enabled = true
  35. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  36. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  37. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  38. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  39. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  40. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  41. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  42. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  43. pool := newOpenAIWSConnPool(cfg)
  44. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  45. svc := &OpenAIGatewayService{
  46. cfg: cfg,
  47. cache: &stubGatewayCache{},
  48. httpUpstream: &httpUpstreamRecorder{},
  49. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  50. openaiWSPool: pool,
  51. toolCorrector: NewCodexToolCorrector(),
  52. }
  53. account := &Account{
  54. ID: 2,
  55. Name: "ws-test",
  56. Platform: PlatformOpenAI,
  57. Type: AccountTypeAPIKey,
  58. Status: StatusActive,
  59. Schedulable: true,
  60. Concurrency: 1,
  61. Credentials: map[string]any{"api_key": "sk-test"},
  62. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  63. }
  64. recorder := httptest.NewRecorder()
  65. c, _ := gin.CreateTestContext(recorder)
  66. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  67. groupID := int64(1)
  68. c.Set("api_key", &APIKey{GroupID: &groupID})
  69. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
  70. require.NoError(t, err)
  71. require.NotNil(t, result)
  72. require.Equal(t, 7, result.Usage.InputTokens)
  73. require.Equal(t, 9, result.Usage.OutputTokens)
  74. require.Nil(t, result.FirstTokenMs)
  75. assertOpenAISSEFrames(t, recorder.Body.String(), []string{
  76. "response.in_progress",
  77. "response.output_item.added",
  78. "response.completed",
  79. })
  80. }
  81. func TestOpenAIWSv2RejectsMalformedTypedEventBeforeWritingDownstream(t *testing.T) {
  82. largeInProgress, _, _ := openAIConcatenatedJSONTestEvents(t)
  83. testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte(largeInProgress+"unexpected-tail"))
  84. }
  85. func TestOpenAIWSv2RejectsMalformedUntypedMessageBeforeWritingDownstream(t *testing.T) {
  86. testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte("not-json"))
  87. }
  88. func TestOpenAIWSv2RejectsMalformedEventAfterWritingDownstream(t *testing.T) {
  89. gin.SetMode(gin.TestMode)
  90. outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":1}`
  91. malformedMessage := `{"type":"response.in_progress"}unexpected-tail`
  92. captureConn := &openAIWSCaptureConn{events: [][]byte{
  93. []byte(outputTextDelta),
  94. []byte(malformedMessage),
  95. }}
  96. cfg := &config.Config{}
  97. cfg.Security.URLAllowlist.Enabled = false
  98. cfg.Gateway.OpenAIWS.Enabled = true
  99. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  100. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  101. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  102. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  103. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  104. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  105. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  106. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  107. pool := newOpenAIWSConnPool(cfg)
  108. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  109. svc := &OpenAIGatewayService{
  110. cfg: cfg,
  111. cache: &stubGatewayCache{},
  112. httpUpstream: &httpUpstreamRecorder{},
  113. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  114. openaiWSPool: pool,
  115. toolCorrector: NewCodexToolCorrector(),
  116. }
  117. account := &Account{
  118. ID: 5,
  119. Name: "ws-malformed-event-after-output",
  120. Platform: PlatformOpenAI,
  121. Type: AccountTypeAPIKey,
  122. Status: StatusActive,
  123. Schedulable: true,
  124. Concurrency: 1,
  125. Credentials: map[string]any{"api_key": "sk-test"},
  126. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  127. }
  128. recorder := httptest.NewRecorder()
  129. c, _ := gin.CreateTestContext(recorder)
  130. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  131. groupID := int64(1)
  132. c.Set("api_key", &APIKey{GroupID: &groupID})
  133. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
  134. require.Error(t, err)
  135. require.Contains(t, err.Error(), "after downstream output")
  136. require.Nil(t, result)
  137. require.True(t, captureConn.closed)
  138. require.Contains(t, recorder.Body.String(), `"delta":"ok"`)
  139. require.NotContains(t, recorder.Body.String(), "unexpected-tail")
  140. require.NotContains(t, recorder.Body.String(), "response.in_progress")
  141. assertOpenAISSEFrames(t, recorder.Body.String(), []string{"response.output_text.delta"})
  142. }
  143. func testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t *testing.T, malformedMessage []byte) {
  144. t.Helper()
  145. gin.SetMode(gin.TestMode)
  146. _, _, completed := openAIConcatenatedJSONTestEvents(t)
  147. outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":3}`
  148. captureConn := &openAIWSCaptureConn{events: [][]byte{
  149. malformedMessage,
  150. []byte(outputTextDelta),
  151. []byte(completed),
  152. }}
  153. cfg := &config.Config{}
  154. cfg.Security.URLAllowlist.Enabled = false
  155. cfg.Gateway.OpenAIWS.Enabled = true
  156. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  157. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  158. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  159. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  160. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  161. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  162. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  163. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  164. pool := newOpenAIWSConnPool(cfg)
  165. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  166. svc := &OpenAIGatewayService{
  167. cfg: cfg,
  168. cache: &stubGatewayCache{},
  169. httpUpstream: &httpUpstreamRecorder{},
  170. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  171. openaiWSPool: pool,
  172. toolCorrector: NewCodexToolCorrector(),
  173. }
  174. account := &Account{
  175. ID: 4,
  176. Name: "ws-malformed-event",
  177. Platform: PlatformOpenAI,
  178. Type: AccountTypeAPIKey,
  179. Status: StatusActive,
  180. Schedulable: true,
  181. Concurrency: 1,
  182. Credentials: map[string]any{"api_key": "sk-test"},
  183. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  184. }
  185. recorder := httptest.NewRecorder()
  186. c, _ := gin.CreateTestContext(recorder)
  187. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  188. groupID := int64(1)
  189. c.Set("api_key", &APIKey{GroupID: &groupID})
  190. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
  191. require.Error(t, err)
  192. var fallbackErr *openAIWSFallbackError
  193. require.ErrorAs(t, err, &fallbackErr)
  194. require.Equal(t, "invalid_event_json", fallbackErr.Reason)
  195. require.Nil(t, result)
  196. require.Empty(t, recorder.Body.String())
  197. require.True(t, captureConn.closed)
  198. }
  199. func TestSplitOpenAIConcatenatedJSONDocumentsRejectsPayloadOverRepairLimit(t *testing.T) {
  200. first := `{"type":"response.in_progress","padding":"` + strings.Repeat("x", 16*1024*1024) + `"}`
  201. second := `{"type":"response.completed"}`
  202. payload := first + second
  203. documents, repaired := splitOpenAIConcatenatedJSONDocuments([]byte(payload))
  204. require.False(t, repaired)
  205. require.Nil(t, documents)
  206. line := "data: " + payload
  207. scanner := bufio.NewScanner(strings.NewReader(line))
  208. scanner.Buffer(make([]byte, 1024), len(line)+1)
  209. documentScanner := newOpenAISSEJSONDocumentScanner(scanner)
  210. require.True(t, documentScanner.Scan())
  211. require.Equal(t, line, documentScanner.Text())
  212. require.False(t, documentScanner.Scan())
  213. require.NoError(t, documentScanner.Err())
  214. }
  215. func TestOpenAIWSv2StreamingBreaksConnectionWhenTerminalHasTrailingDocument(t *testing.T) {
  216. gin.SetMode(gin.TestMode)
  217. completed := `{"type":"response.completed","response":{"id":"resp_terminal_tail","usage":{"input_tokens":2,"output_tokens":1}}}`
  218. tail := `{"type":"error","error":{"type":"upstream_error","message":"tail"}}`
  219. captureConn := &openAIWSCaptureConn{events: [][]byte{[]byte(completed + tail)}}
  220. cfg := &config.Config{}
  221. cfg.Security.URLAllowlist.Enabled = false
  222. cfg.Gateway.OpenAIWS.Enabled = true
  223. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  224. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  225. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  226. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  227. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  228. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  229. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  230. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  231. pool := newOpenAIWSConnPool(cfg)
  232. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  233. svc := &OpenAIGatewayService{
  234. cfg: cfg,
  235. cache: &stubGatewayCache{},
  236. httpUpstream: &httpUpstreamRecorder{},
  237. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  238. openaiWSPool: pool,
  239. toolCorrector: NewCodexToolCorrector(),
  240. }
  241. account := &Account{
  242. ID: 3,
  243. Name: "ws-terminal-tail",
  244. Platform: PlatformOpenAI,
  245. Type: AccountTypeAPIKey,
  246. Status: StatusActive,
  247. Schedulable: true,
  248. Concurrency: 1,
  249. Credentials: map[string]any{"api_key": "sk-test"},
  250. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  251. }
  252. recorder := httptest.NewRecorder()
  253. c, _ := gin.CreateTestContext(recorder)
  254. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  255. groupID := int64(1)
  256. c.Set("api_key", &APIKey{GroupID: &groupID})
  257. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
  258. require.NoError(t, err)
  259. require.NotNil(t, result)
  260. require.True(t, captureConn.closed, "a WS message with data after a terminal event must not return to the pool")
  261. assertOpenAISSEFrames(t, recorder.Body.String(), []string{"response.completed"})
  262. }
  263. func testOpenAIStreamingRepairsConcatenatedJSONDocuments(t *testing.T, passthrough bool, streamDataIntervalTimeout int) {
  264. t.Helper()
  265. gin.SetMode(gin.TestMode)
  266. largeInProgress, outputItemAdded, completed := openAIConcatenatedJSONTestEvents(t)
  267. upstreamBody := strings.Join([]string{
  268. "event: response.in_progress",
  269. "data: " + largeInProgress + outputItemAdded,
  270. "",
  271. "event: response.completed",
  272. "data: " + completed,
  273. "",
  274. }, "\n")
  275. resp := &http.Response{
  276. StatusCode: http.StatusOK,
  277. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  278. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  279. }
  280. recorder := httptest.NewRecorder()
  281. c, _ := gin.CreateTestContext(recorder)
  282. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  283. svc := &OpenAIGatewayService{
  284. cfg: &config.Config{Gateway: config.GatewayConfig{
  285. MaxLineSize: defaultMaxLineSize,
  286. StreamDataIntervalTimeout: streamDataIntervalTimeout,
  287. }},
  288. toolCorrector: NewCodexToolCorrector(),
  289. }
  290. account := &Account{ID: 1, Name: "test", Platform: PlatformOpenAI}
  291. var usage *OpenAIUsage
  292. var err error
  293. if passthrough {
  294. result, forwardErr := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-sol", "gpt-5.6-sol")
  295. err = forwardErr
  296. if result != nil {
  297. usage = result.usage
  298. }
  299. } else {
  300. result, forwardErr := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-sol", "gpt-5.6-sol")
  301. err = forwardErr
  302. if result != nil {
  303. usage = result.usage
  304. }
  305. }
  306. require.NoError(t, err)
  307. require.NotNil(t, usage)
  308. require.Equal(t, 7, usage.InputTokens)
  309. require.Equal(t, 9, usage.OutputTokens)
  310. assertOpenAISSEFrames(t, recorder.Body.String(), []string{
  311. "response.in_progress",
  312. "response.output_item.added",
  313. "response.completed",
  314. })
  315. }
  316. func assertOpenAISSEFrames(t *testing.T, body string, expectedTypes []string) {
  317. t.Helper()
  318. var parser openAICompatSSEFrameParser
  319. var eventTypes []string
  320. for _, line := range strings.Split(body, "\n") {
  321. frame, ok := parser.AddLine(strings.TrimSuffix(line, "\r"))
  322. if !ok {
  323. continue
  324. }
  325. require.True(t, json.Valid([]byte(frame.Data)), "each downstream SSE frame must contain exactly one JSON document")
  326. var event struct {
  327. Type string `json:"type"`
  328. }
  329. require.NoError(t, json.Unmarshal([]byte(frame.Data), &event))
  330. if frame.EventType != "" {
  331. require.Equal(t, event.Type, frame.EventType)
  332. }
  333. eventTypes = append(eventTypes, event.Type)
  334. }
  335. if frame, ok := parser.Finish(); ok {
  336. require.True(t, json.Valid([]byte(frame.Data)))
  337. var event struct {
  338. Type string `json:"type"`
  339. }
  340. require.NoError(t, json.Unmarshal([]byte(frame.Data), &event))
  341. eventTypes = append(eventTypes, event.Type)
  342. }
  343. require.Equal(t, expectedTypes, eventTypes)
  344. }
  345. func openAIConcatenatedJSONTestEvents(t *testing.T) (string, string, string) {
  346. t.Helper()
  347. const javascriptErrorPosition = 68106
  348. prefix := `{"type":"response.in_progress","response":{"id":"resp_large","status":"in_progress","instructions":"`
  349. suffix := `"},"sequence_number":1}`
  350. require.Less(t, len(prefix)+len(suffix), javascriptErrorPosition)
  351. largeInProgress := prefix + strings.Repeat("x", javascriptErrorPosition-len(prefix)-len(suffix)) + suffix
  352. outputItemAdded := `{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","role":"assistant","status":"in_progress","content":[]},"sequence_number":2}`
  353. completed := `{"type":"response.completed","response":{"id":"resp_large","status":"completed","output":[],"usage":{"input_tokens":7,"output_tokens":9}},"sequence_number":3}`
  354. require.Len(t, largeInProgress, javascriptErrorPosition)
  355. require.True(t, json.Valid([]byte(largeInProgress)))
  356. var decoded any
  357. err := json.Unmarshal([]byte(largeInProgress+outputItemAdded), &decoded)
  358. var syntaxErr *json.SyntaxError
  359. require.ErrorAs(t, err, &syntaxErr)
  360. require.Equal(t, int64(javascriptErrorPosition+1), syntaxErr.Offset)
  361. return largeInProgress, outputItemAdded, completed
  362. }