openai_first_output_timeout_test.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "os"
  10. "runtime"
  11. "strings"
  12. "sync"
  13. "sync/atomic"
  14. "testing"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/config"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  18. "github.com/gin-gonic/gin"
  19. "github.com/stretchr/testify/require"
  20. )
  21. type blockingOpenAIResponseHeaderUpstream struct {
  22. canceled chan struct{}
  23. once sync.Once
  24. }
  25. type firstOutputCloseTrackingBody struct {
  26. io.ReadCloser
  27. closed chan struct{}
  28. once sync.Once
  29. }
  30. func (b *firstOutputCloseTrackingBody) Close() error {
  31. b.once.Do(func() { close(b.closed) })
  32. return b.ReadCloser.Close()
  33. }
  34. func (u *blockingOpenAIResponseHeaderUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  35. select {
  36. case <-req.Context().Done():
  37. u.once.Do(func() { close(u.canceled) })
  38. return nil, req.Context().Err()
  39. case <-time.After(1500 * time.Millisecond):
  40. return nil, errors.New("test upstream was not canceled before response headers")
  41. }
  42. }
  43. func (u *blockingOpenAIResponseHeaderUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  44. return u.Do(req, "", 0, 0)
  45. }
  46. func TestOpenAIForwardFirstOutputTimeoutIncludesResponseHeaderWait(t *testing.T) {
  47. gin.SetMode(gin.TestMode)
  48. upstream := &blockingOpenAIResponseHeaderUpstream{canceled: make(chan struct{})}
  49. svc := &OpenAIGatewayService{
  50. cfg: &config.Config{Gateway: config.GatewayConfig{
  51. OpenAIFirstOutputTimeoutSeconds: 1,
  52. MaxLineSize: defaultMaxLineSize,
  53. }},
  54. httpUpstream: upstream,
  55. }
  56. body := []byte(`{"model":"gpt-5.5","stream":true,"reasoning":{"effort":"low"},"input":"hello"}`)
  57. rec := httptest.NewRecorder()
  58. c, _ := gin.CreateTestContext(rec)
  59. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  60. account := &Account{
  61. ID: 1, Name: "oauth-test", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  62. Status: StatusActive, Schedulable: true, Concurrency: 1,
  63. Credentials: map[string]any{"access_token": "test-token", "chatgpt_account_id": "test-account"},
  64. }
  65. started := time.Now()
  66. _, err := svc.Forward(context.Background(), c, account, body)
  67. require.Error(t, err)
  68. var failoverErr *UpstreamFailoverError
  69. require.ErrorAs(t, err, &failoverErr)
  70. require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
  71. require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
  72. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  73. require.Less(t, time.Since(started), 1300*time.Millisecond)
  74. require.Empty(t, rec.Body.String())
  75. select {
  76. case <-upstream.canceled:
  77. default:
  78. t.Fatal("response-header timeout did not cancel the upstream request context")
  79. }
  80. }
  81. func TestOpenAINativeFirstOutputTimeoutDisabledPreservesSynchronousStream(t *testing.T) {
  82. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  83. OpenAIFirstOutputTimeoutSeconds: 0,
  84. MaxLineSize: defaultMaxLineSize,
  85. }}}
  86. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  87. `data: {"type":"response.created","response":{"id":"resp_disabled"}}`,
  88. "",
  89. `data: {"type":"response.completed","response":{"id":"resp_disabled","usage":{"input_tokens":1,"output_tokens":1}}}`,
  90. "",
  91. }, "\n")))}
  92. rec := httptest.NewRecorder()
  93. c, _ := gin.CreateTestContext(rec)
  94. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  95. result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  96. require.NoError(t, err)
  97. require.NotNil(t, result)
  98. require.Contains(t, rec.Body.String(), "response.completed")
  99. }
  100. func TestOpenAINativeFirstOutputTimeoutIgnoresPreambleAndCleansReader(t *testing.T) {
  101. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  102. OpenAIFirstOutputTimeoutSeconds: 1,
  103. MaxLineSize: defaultMaxLineSize,
  104. }}}
  105. pr, pw := io.Pipe()
  106. writerDone := make(chan struct{})
  107. go func() {
  108. defer close(writerDone)
  109. defer func() { _ = pw.Close() }()
  110. _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_slow\"}}\n\n"))
  111. _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_slow\"}}\n\n"))
  112. time.Sleep(200 * time.Millisecond)
  113. }()
  114. rec := httptest.NewRecorder()
  115. c, _ := gin.CreateTestContext(rec)
  116. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  117. body := &firstOutputCloseTrackingBody{ReadCloser: pr, closed: make(chan struct{})}
  118. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: body}
  119. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now().Add(-2*time.Second), "model", "model")
  120. require.Error(t, err)
  121. var failoverErr *UpstreamFailoverError
  122. require.ErrorAs(t, err, &failoverErr)
  123. require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
  124. require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
  125. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  126. require.Empty(t, rec.Body.String())
  127. select {
  128. case <-body.closed:
  129. default:
  130. t.Fatal("first-output timeout did not close the upstream response body")
  131. }
  132. select {
  133. case <-writerDone:
  134. case <-time.After(time.Second):
  135. t.Fatal("stream reader/writer goroutine did not exit after first-output timeout")
  136. }
  137. }
  138. func TestNewOpenAIFirstOutputTimeoutErrorRecordsCallerProxyAttribution(t *testing.T) {
  139. gin.SetMode(gin.TestMode)
  140. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  141. svc := &OpenAIGatewayService{}
  142. proxyID := int64(10060)
  143. account := &Account{ID: 1, Name: "acc", Platform: PlatformOpenAI, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}}
  144. // WebSocket callers pass the WS attribution; HTTP callers pass the HTTP one.
  145. wsID, wsName := opsUpstreamWSProxyAttribution(&Account{ID: 2, Platform: PlatformOpenAI})
  146. _ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, &Account{ID: 2, Name: "ws", Platform: PlatformOpenAI},
  147. wsID, wsName, time.Now(), "gpt-5.5", "", time.Second, "websocket_first_semantic_output", nil)
  148. _ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, account,
  149. opsUpstreamProxyID(account), opsUpstreamProxyName(account), time.Now(), "gpt-5.5", "", time.Second, "semantic_output", nil)
  150. raw, ok := c.Get(OpsUpstreamErrorsKey)
  151. require.True(t, ok)
  152. events, ok := raw.([]*OpsUpstreamErrorEvent)
  153. require.True(t, ok)
  154. require.Len(t, events, 2)
  155. require.Equal(t, "first_output_timeout", events[0].Kind)
  156. require.Nil(t, events[0].ProxyID)
  157. require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
  158. require.NotNil(t, events[1].ProxyID)
  159. require.Equal(t, proxyID, *events[1].ProxyID)
  160. require.Equal(t, "ws-proxy", events[1].ProxyName)
  161. }
  162. func TestOpenAIFirstOutputTimeoutForReasoningEffort(t *testing.T) {
  163. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  164. OpenAIFirstOutputTimeoutSeconds: 120,
  165. OpenAIHighEffortFirstOutputTimeoutSeconds: 300,
  166. }}}
  167. require.Equal(t, 120*time.Second, svc.openAIFirstOutputTimeout("low"))
  168. require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("high"))
  169. require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("xhigh"))
  170. require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("max"))
  171. }
  172. func TestOpenAIFirstOutputStageDefaultLimitIsIndependentFromScannerLimit(t *testing.T) {
  173. stage := newDefaultOpenAIFirstOutputStage()
  174. defer func() { require.NoError(t, stage.Close()) }()
  175. require.EqualValues(t, 8*1024*1024, stage.limit)
  176. require.Greater(t, stage.limit, int64(68106))
  177. require.Less(t, stage.limit, int64(defaultMaxLineSize))
  178. }
  179. func TestOpenAIFirstOutputEventQueueSizeBackpressuresGuardedStreams(t *testing.T) {
  180. require.Equal(t, 1, openAIFirstOutputEventQueueSize(true))
  181. require.Equal(t, 16, openAIFirstOutputEventQueueSize(false))
  182. }
  183. func TestOpenAIFirstOutputDynamicScannerLimitsOnlyWhileGuardIsActive(t *testing.T) {
  184. var guardActive atomic.Bool
  185. guardActive.Store(true)
  186. split := openAIFirstOutputDynamicScanLines(&guardActive)
  187. guardLimit := openAIFirstOutputStageMaxBytes + openAIFirstOutputScannerFramingAllowance
  188. undelimited := bytes.Repeat([]byte("x"), guardLimit)
  189. _, _, err := split(undelimited, false)
  190. require.ErrorIs(t, err, errOpenAIFirstOutputScannerLimit)
  191. guardActive.Store(false)
  192. advance, token, err := split(undelimited, false)
  193. require.NoError(t, err)
  194. require.Zero(t, advance)
  195. require.Nil(t, token)
  196. }
  197. func TestOpenAIFirstOutputStageOverflowIsAtomicAndCleanupRemovesSpool(t *testing.T) {
  198. stage := newOpenAIFirstOutputStage(70 * 1024)
  199. payload := bytes.Repeat([]byte("x"), 68*1024)
  200. n, err := stage.Write(payload)
  201. require.NoError(t, err)
  202. require.Equal(t, len(payload), n)
  203. if runtime.GOOS == "windows" {
  204. require.Nil(t, stage.tempFile)
  205. require.Empty(t, stage.tempPath)
  206. } else {
  207. require.NotNil(t, stage.tempFile)
  208. require.NotEmpty(t, stage.tempPath)
  209. _, err = os.Stat(stage.tempPath)
  210. require.ErrorIs(t, err, os.ErrNotExist)
  211. stat, statErr := stage.tempFile.Stat()
  212. require.NoError(t, statErr)
  213. require.Equal(t, os.FileMode(0o600), stat.Mode().Perm())
  214. }
  215. n, err = stage.Write(bytes.Repeat([]byte("y"), 3*1024))
  216. require.Zero(t, n)
  217. require.ErrorIs(t, err, errOpenAIFirstOutputStageLimit)
  218. require.EqualValues(t, len(payload), stage.Buffered())
  219. path := stage.tempPath
  220. require.NoError(t, stage.Close())
  221. require.True(t, stage.closed)
  222. require.Nil(t, stage.tempFile)
  223. require.Empty(t, stage.tempPath)
  224. if path != "" {
  225. _, err = os.Stat(path)
  226. require.ErrorIs(t, err, os.ErrNotExist)
  227. }
  228. }
  229. func TestOpenAIFirstOutputStageCommitCopiesSpoolAndRemovesTemp(t *testing.T) {
  230. stage := newOpenAIFirstOutputStage(80 * 1024)
  231. payload := bytes.Repeat([]byte("z"), 68*1024)
  232. _, err := stage.Write(payload)
  233. require.NoError(t, err)
  234. path := stage.tempPath
  235. if runtime.GOOS == "windows" {
  236. require.Empty(t, path)
  237. require.Nil(t, stage.tempFile)
  238. } else {
  239. require.NotEmpty(t, path)
  240. require.NotNil(t, stage.tempFile)
  241. _, statErr := os.Stat(path)
  242. require.ErrorIs(t, statErr, os.ErrNotExist)
  243. }
  244. var downstream bytes.Buffer
  245. require.NoError(t, stage.CommitTo(&downstream))
  246. require.Equal(t, payload, downstream.Bytes())
  247. require.Zero(t, stage.Buffered())
  248. if path != "" {
  249. _, err = os.Stat(path)
  250. require.ErrorIs(t, err, os.ErrNotExist)
  251. }
  252. require.NoError(t, stage.Close())
  253. }
  254. func TestOpenAIFirstOutputStageUnlinkFailurePermanentlyFallsBackToMemoryAndRetriesCleanup(t *testing.T) {
  255. stage := newDefaultOpenAIFirstOutputStage()
  256. stage.memoryOnly = false
  257. t.Cleanup(func() {
  258. stage.removeFile = os.Remove
  259. _ = stage.Close()
  260. })
  261. createCalls := 0
  262. stage.createTemp = func() (*os.File, error) {
  263. createCalls++
  264. return os.CreateTemp("", "sub2api-openai-first-output-fallback-*")
  265. }
  266. removeCalls := 0
  267. stage.removeFile = func(path string) error {
  268. removeCalls++
  269. if removeCalls <= 2 {
  270. return errors.New("forced remove failure")
  271. }
  272. return os.Remove(path)
  273. }
  274. payload := bytes.Repeat([]byte("m"), 68*1024)
  275. _, err := stage.Write(payload)
  276. require.NoError(t, err)
  277. require.True(t, stage.memoryOnly)
  278. require.Nil(t, stage.tempFile)
  279. require.NotEmpty(t, stage.tempPath)
  280. require.Equal(t, 1, createCalls)
  281. stat, statErr := os.Stat(stage.tempPath)
  282. require.NoError(t, statErr)
  283. require.Zero(t, stat.Size(), "failed-unlink fallback must never write plaintext to the named file")
  284. _, err = stage.WriteString("more")
  285. require.NoError(t, err)
  286. require.Equal(t, 1, createCalls, "memory-only fallback must not retry CreateTemp")
  287. path := stage.tempPath
  288. cleanupErr := stage.Close()
  289. require.ErrorContains(t, cleanupErr, "forced remove failure")
  290. require.Empty(t, stage.tempPath)
  291. _, err = os.Stat(path)
  292. require.ErrorIs(t, err, os.ErrNotExist)
  293. require.NoError(t, stage.Close())
  294. }
  295. func TestOpenAINativeFirstOutputTimeoutDisarmsAfterSemanticOutput(t *testing.T) {
  296. cfg := &config.Config{Gateway: config.GatewayConfig{
  297. OpenAIFirstOutputTimeoutSeconds: 1,
  298. MaxLineSize: defaultMaxLineSize,
  299. }}
  300. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  301. pr, pw := io.Pipe()
  302. go func() {
  303. defer func() { _ = pw.Close() }()
  304. _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ok\"}}\n\n"))
  305. _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"))
  306. time.Sleep(1100 * time.Millisecond)
  307. _, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"))
  308. }()
  309. rec := httptest.NewRecorder()
  310. c, _ := gin.CreateTestContext(rec)
  311. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  312. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{
  313. "X-Request-Id": []string{"request-winning"},
  314. "X-Ratelimit-Remaining-Requests": []string{"42"},
  315. }, Body: pr}
  316. result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  317. require.NoError(t, err)
  318. require.NotNil(t, result)
  319. require.NotNil(t, result.firstTokenMs)
  320. require.Contains(t, rec.Body.String(), "response.output_text.delta")
  321. require.Contains(t, rec.Body.String(), "response.completed")
  322. require.Equal(t, "request-winning", rec.Result().Header.Get("X-Request-Id"))
  323. require.Equal(t, "42", rec.Result().Header.Get("X-Ratelimit-Remaining-Requests"))
  324. }
  325. func TestOpenAINativeFirstOutputTimeoutWaitsForCompleteSemanticEvent(t *testing.T) {
  326. const lineSize = 68106
  327. prefix := `data: {"type":"response.output_text.delta","delta":"`
  328. suffix := `"}`
  329. line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
  330. require.Len(t, line, lineSize)
  331. assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t, line)
  332. }
  333. func TestOpenAINativeFirstOutputTimeoutDoesNotLeakLargePreambleEvent(t *testing.T) {
  334. const lineSize = 68106
  335. prefix := `data: {"type":"response.created","response":{"id":"resp_partial","padding":"`
  336. suffix := `"}}`
  337. line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
  338. require.Len(t, line, lineSize)
  339. assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t, line)
  340. }
  341. func assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t *testing.T, line string) {
  342. t.Helper()
  343. cfg := &config.Config{Gateway: config.GatewayConfig{
  344. OpenAIFirstOutputTimeoutSeconds: 1,
  345. StreamKeepaliveInterval: 1,
  346. MaxLineSize: defaultMaxLineSize,
  347. }}
  348. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  349. pr, pw := io.Pipe()
  350. body := &firstOutputCloseTrackingBody{ReadCloser: pr, closed: make(chan struct{})}
  351. writerDone := make(chan struct{})
  352. go func() {
  353. defer close(writerDone)
  354. defer func() { _ = pw.Close() }()
  355. _, _ = pw.Write([]byte(line + "\n"))
  356. select {
  357. case <-body.closed:
  358. case <-time.After(2 * time.Second):
  359. }
  360. }()
  361. rec := httptest.NewRecorder()
  362. c, _ := gin.CreateTestContext(rec)
  363. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  364. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{
  365. "X-Request-Id": []string{"request-partial"},
  366. "X-Ratelimit-Remaining-Requests": []string{"1"},
  367. }, Body: body}
  368. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  369. var failoverErr *UpstreamFailoverError
  370. require.ErrorAs(t, err, &failoverErr)
  371. require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
  372. require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
  373. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  374. require.NotContains(t, rec.Body.String(), "data:", "attempt JSON must remain private before the SSE boundary")
  375. require.NotContains(t, rec.Body.String(), `"type"`, "attempt JSON must remain private before the SSE boundary")
  376. for _, outputLine := range strings.Split(strings.TrimSpace(rec.Body.String()), "\n") {
  377. if outputLine != "" {
  378. require.True(t, strings.HasPrefix(outputLine, ":"), "only keepalive comments may precede failover: %q", outputLine)
  379. }
  380. }
  381. require.Empty(t, rec.Header().Values("X-Request-Id"))
  382. require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
  383. select {
  384. case <-writerDone:
  385. case <-time.After(time.Second):
  386. t.Fatal("partial-event writer did not exit after timeout closed the body")
  387. }
  388. }
  389. func TestOpenAINativeFirstOutputEOFDispatchesTerminalEventWithoutBlankLine(t *testing.T) {
  390. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
  391. openAITTFTMode: OpenAITTFTModeVisible,
  392. expiresAt: time.Now().Add(time.Minute).UnixNano(),
  393. })
  394. t.Cleanup(func() {
  395. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
  396. openAITTFTMode: OpenAITTFTModeSemantic,
  397. expiresAt: time.Now().Add(time.Minute).UnixNano(),
  398. })
  399. })
  400. cfg := &config.Config{Gateway: config.GatewayConfig{
  401. OpenAIFirstOutputTimeoutSeconds: 1,
  402. MaxLineSize: defaultMaxLineSize,
  403. }}
  404. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  405. payload := `data: {"type":"response.completed","response":{"id":"resp_eof","usage":{"input_tokens":3,"output_tokens":2}}}`
  406. rec := httptest.NewRecorder()
  407. c, _ := gin.CreateTestContext(rec)
  408. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  409. resp := &http.Response{
  410. StatusCode: http.StatusOK,
  411. Header: http.Header{
  412. "X-Request-Id": []string{"request-eof"},
  413. "X-Ratelimit-Remaining-Requests": []string{"17"},
  414. },
  415. Body: io.NopCloser(strings.NewReader(payload)),
  416. }
  417. result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  418. require.NoError(t, err)
  419. require.NotNil(t, result)
  420. require.Nil(t, result.firstTokenMs, "usage-only terminal event is not visible output")
  421. require.Equal(t, "resp_eof", result.responseID)
  422. require.Equal(t, 3, result.usage.InputTokens)
  423. require.Equal(t, 2, result.usage.OutputTokens)
  424. require.Contains(t, rec.Body.String(), `"type":"response.completed"`)
  425. require.Contains(t, rec.Body.String(), `"id":"resp_eof"`)
  426. require.True(t, strings.HasSuffix(rec.Body.String(), "\n"))
  427. require.False(t, strings.HasSuffix(rec.Body.String(), "\n\n"), "EOF dispatch must not synthesize a blank line")
  428. require.Equal(t, "request-eof", rec.Result().Header.Get("X-Request-Id"))
  429. require.Equal(t, "17", rec.Result().Header.Get("X-Ratelimit-Remaining-Requests"))
  430. }
  431. func TestOpenAINativeFirstOutputStageOverflowFailsOverWithoutAttemptBytes(t *testing.T) {
  432. cfg := &config.Config{Gateway: config.GatewayConfig{
  433. OpenAIFirstOutputTimeoutSeconds: 30,
  434. MaxLineSize: 2 * 1024 * 1024,
  435. }}
  436. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  437. const lineSize = 1024*1024 - 256
  438. prefix := `data: {"type":"response.output_text.delta","delta":"`
  439. suffix := `"}`
  440. line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
  441. body := strings.Repeat(line+"\n", 9)
  442. rec := httptest.NewRecorder()
  443. c, _ := gin.CreateTestContext(rec)
  444. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  445. resp := &http.Response{
  446. StatusCode: http.StatusOK,
  447. Header: http.Header{
  448. "X-Request-Id": []string{"request-overflow"},
  449. "X-Ratelimit-Remaining-Requests": []string{"1"},
  450. },
  451. Body: io.NopCloser(strings.NewReader(body)),
  452. }
  453. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  454. var failoverErr *UpstreamFailoverError
  455. require.ErrorAs(t, err, &failoverErr)
  456. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  457. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  458. require.Contains(t, string(failoverErr.ResponseBody), "staging limit exceeded")
  459. require.Empty(t, rec.Body.String())
  460. require.Empty(t, rec.Header().Values("X-Request-Id"))
  461. require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
  462. }
  463. func TestOpenAINativeFirstOutputScannerRejectsOversizedLineWithoutLeak(t *testing.T) {
  464. cfg := &config.Config{Gateway: config.GatewayConfig{
  465. OpenAIFirstOutputTimeoutSeconds: 30,
  466. MaxLineSize: defaultMaxLineSize,
  467. }}
  468. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  469. oversizedLine := "data: " + strings.Repeat("x", openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance+1024)
  470. body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_private\"}}\n\n" + oversizedLine + "\n"
  471. rec := httptest.NewRecorder()
  472. c, _ := gin.CreateTestContext(rec)
  473. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  474. resp := &http.Response{
  475. StatusCode: http.StatusOK,
  476. Header: http.Header{
  477. "X-Request-Id": []string{"request-too-large"},
  478. "X-Ratelimit-Remaining-Requests": []string{"1"},
  479. },
  480. Body: io.NopCloser(strings.NewReader(body)),
  481. }
  482. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  483. var failoverErr *UpstreamFailoverError
  484. require.ErrorAs(t, err, &failoverErr)
  485. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  486. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  487. require.Contains(t, string(failoverErr.ResponseBody), "line exceeds guarded first-output limit")
  488. require.Empty(t, rec.Body.String())
  489. require.Empty(t, rec.Header().Values("X-Request-Id"))
  490. require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
  491. }
  492. func TestOpenAINativeFirstOutputScannerAllowsLargeEventAfterSemanticBoundary(t *testing.T) {
  493. cfg := &config.Config{Gateway: config.GatewayConfig{
  494. OpenAIFirstOutputTimeoutSeconds: 30,
  495. MaxLineSize: defaultMaxLineSize,
  496. }}
  497. svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
  498. largeDelta := strings.Repeat("i", openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance+1024)
  499. body := strings.Join([]string{
  500. `data: {"type":"response.output_text.delta","delta":"ready"}`,
  501. "",
  502. `data: {"type":"response.output_text.delta","delta":"` + largeDelta + `"}`,
  503. "",
  504. `data: {"type":"response.completed","response":{"id":"resp_large_image","usage":{"input_tokens":4,"output_tokens":3}}}`,
  505. "",
  506. }, "\n")
  507. rec := httptest.NewRecorder()
  508. c, _ := gin.CreateTestContext(rec)
  509. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  510. resp := &http.Response{
  511. StatusCode: http.StatusOK,
  512. Header: http.Header{"X-Request-Id": []string{"request-large-image"}},
  513. Body: io.NopCloser(strings.NewReader(body)),
  514. }
  515. result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  516. require.NoError(t, err)
  517. require.NotNil(t, result)
  518. require.NotNil(t, result.firstTokenMs)
  519. require.Equal(t, "resp_large_image", result.responseID)
  520. require.Equal(t, 4, result.usage.InputTokens)
  521. require.Equal(t, 3, result.usage.OutputTokens)
  522. require.Contains(t, rec.Body.String(), `"delta":"ready"`)
  523. require.Contains(t, rec.Body.String(), `"id":"resp_large_image"`)
  524. require.Contains(t, rec.Body.String(), strings.Repeat("i", 1024))
  525. require.Equal(t, "request-large-image", rec.Result().Header.Get("X-Request-Id"))
  526. }
  527. func TestOpenAINativeFirstOutputTimeoutDisabledKeepsPreamblePrivateAcrossKeepalive(t *testing.T) {
  528. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  529. StreamKeepaliveInterval: 1,
  530. MaxLineSize: defaultMaxLineSize,
  531. }}}
  532. pr, pw := io.Pipe()
  533. go func() {
  534. defer func() { _ = pw.Close() }()
  535. _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_stalled\"}}\n\n"))
  536. _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_stalled\"}}\n\n"))
  537. time.Sleep(2100 * time.Millisecond)
  538. }()
  539. rec := httptest.NewRecorder()
  540. c, _ := gin.CreateTestContext(rec)
  541. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  542. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
  543. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  544. var failoverErr *UpstreamFailoverError
  545. require.ErrorAs(t, err, &failoverErr)
  546. require.Contains(t, rec.Body.String(), ":\n\n")
  547. require.NotContains(t, rec.Body.String(), "response.created")
  548. require.NotContains(t, rec.Body.String(), "response.in_progress")
  549. }
  550. func TestOpenAINativeFirstOutputFailoverKeepsAttemptHeadersPrivateAfterKeepaliveCommit(t *testing.T) {
  551. cfg := &config.Config{Gateway: config.GatewayConfig{
  552. OpenAIFirstOutputTimeoutSeconds: 2,
  553. StreamKeepaliveInterval: 1,
  554. MaxLineSize: defaultMaxLineSize,
  555. }}
  556. svc := &OpenAIGatewayService{
  557. cfg: cfg,
  558. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  559. }
  560. rec := httptest.NewRecorder()
  561. c, _ := gin.CreateTestContext(rec)
  562. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  563. firstBody, firstWriter := io.Pipe()
  564. trackedFirstBody := &firstOutputCloseTrackingBody{ReadCloser: firstBody, closed: make(chan struct{})}
  565. firstWriterDone := make(chan struct{})
  566. go func() {
  567. defer close(firstWriterDone)
  568. defer func() { _ = firstWriter.Close() }()
  569. _, _ = firstWriter.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_first\"}}\n\n"))
  570. select {
  571. case <-trackedFirstBody.closed:
  572. case <-time.After(4 * time.Second):
  573. }
  574. }()
  575. firstResp := &http.Response{
  576. StatusCode: http.StatusOK,
  577. Header: http.Header{
  578. "Content-Type": []string{"text/event-stream"},
  579. "X-Request-Id": []string{"request-first"},
  580. "X-Ratelimit-Remaining-Requests": []string{"1"},
  581. },
  582. Body: trackedFirstBody,
  583. }
  584. _, firstErr := svc.handleStreamingResponse(c.Request.Context(), firstResp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  585. var failoverErr *UpstreamFailoverError
  586. require.ErrorAs(t, firstErr, &failoverErr)
  587. require.Contains(t, rec.Body.String(), ":\n\n", "first attempt should have committed only a stable keepalive")
  588. require.NotContains(t, rec.Body.String(), "resp_first")
  589. secondResp := &http.Response{
  590. StatusCode: http.StatusOK,
  591. Header: http.Header{
  592. "Content-Type": []string{"text/event-stream"},
  593. "X-Request-Id": []string{"request-second"},
  594. "X-Ratelimit-Remaining-Requests": []string{"99"},
  595. },
  596. Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  597. `data: {"type":"response.output_text.delta","delta":"hello"}`,
  598. "",
  599. `data: {"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`,
  600. "",
  601. }, "\n"))),
  602. }
  603. result, secondErr := svc.handleStreamingResponse(c.Request.Context(), secondResp, c, &Account{ID: 2, Platform: PlatformOpenAI}, time.Now(), "model", "model")
  604. require.NoError(t, secondErr)
  605. require.NotNil(t, result)
  606. require.Contains(t, rec.Body.String(), "resp_second")
  607. wireHeaders := rec.Result().Header
  608. require.Empty(t, wireHeaders.Values("X-Request-Id"))
  609. require.Empty(t, wireHeaders.Values("X-Ratelimit-Remaining-Requests"))
  610. require.Empty(t, rec.Header().Values("X-Request-Id"))
  611. require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
  612. select {
  613. case <-firstWriterDone:
  614. case <-time.After(time.Second):
  615. t.Fatal("first account writer did not exit after timeout")
  616. }
  617. }