openai_compact_sse_keepalive_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. package service
  2. import (
  3. "net/http"
  4. "strings"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. "github.com/tidwall/gjson"
  10. )
  11. const keepaliveTestInterval = 10 * time.Millisecond
  12. // waitForKeepaliveBeats 等待至少一次心跳写出。读取 recorder 前必须先经
  13. // StopOpenAICompactSSEKeepaliveCommitted 停拍建立 happens-before。
  14. func waitForKeepaliveBeats() {
  15. time.Sleep(20 * keepaliveTestInterval)
  16. }
  17. // stripKeepaliveComments 去掉 SSE 注释块,返回真实事件文本。
  18. func stripKeepaliveComments(body string) string {
  19. var blocks []string
  20. for _, block := range strings.Split(strings.TrimSpace(body), "\n\n") {
  21. if strings.HasPrefix(strings.TrimSpace(block), ":") {
  22. continue
  23. }
  24. blocks = append(blocks, block)
  25. }
  26. return strings.Join(blocks, "\n\n")
  27. }
  28. func TestStartOpenAICompactSSEKeepalive_NoopWhenUnmarkedOrDisabled(t *testing.T) {
  29. // 未标记 client stream:不启动。
  30. c, rec := newCompactBridgeTestContext(t, false)
  31. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  32. waitForKeepaliveBeats()
  33. stop()
  34. require.Zero(t, rec.Body.Len())
  35. require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c))
  36. // interval=0(配置禁用):不启动。
  37. c, rec = newCompactBridgeTestContext(t, true)
  38. stop = StartOpenAICompactSSEKeepalive(c, 0)
  39. waitForKeepaliveBeats()
  40. stop()
  41. require.Zero(t, rec.Body.Len())
  42. require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c))
  43. }
  44. func TestOpenAICompactSSEKeepalive_CommitsHeadersAndComments(t *testing.T) {
  45. c, rec := newCompactBridgeTestContext(t, true)
  46. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  47. defer stop()
  48. waitForKeepaliveBeats()
  49. require.True(t, StopOpenAICompactSSEKeepaliveCommitted(c))
  50. require.Equal(t, http.StatusOK, rec.Code)
  51. require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
  52. require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
  53. require.Contains(t, rec.Body.String(), ": keepalive\n\n")
  54. }
  55. func TestOpenAICompactSSEKeepalive_StopBeforeFirstBeatKeepsWriterUntouched(t *testing.T) {
  56. c, rec := newCompactBridgeTestContext(t, true)
  57. stop := StartOpenAICompactSSEKeepalive(c, time.Hour)
  58. stop()
  59. waitForKeepaliveBeats()
  60. require.Zero(t, rec.Body.Len())
  61. require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c))
  62. }
  63. func TestOpenAIAdjustedWrittenSizeExcludesResponsesStreamKeepalive(t *testing.T) {
  64. c, rec := newCompactBridgeTestContext(t, false)
  65. n, err := c.Writer.Write([]byte(":\n\n"))
  66. require.NoError(t, err)
  67. recordOpenAIStreamKeepaliveBytes(c, n)
  68. require.Equal(t, -1, OpenAICompactKeepaliveAdjustedWrittenSize(c))
  69. _, err = c.Writer.Write([]byte("data: semantic\n\n"))
  70. require.NoError(t, err)
  71. require.Equal(t, len("data: semantic\n\n"), OpenAICompactKeepaliveAdjustedWrittenSize(c))
  72. require.Equal(t, ":\n\ndata: semantic\n\n", rec.Body.String())
  73. }
  74. // 心跳已提交后,2xx 桥接续写事件而不重复提交响应头。
  75. func TestWriteOpenAICompactSSEBridge_AfterKeepaliveCommitAppendsEvents(t *testing.T) {
  76. c, rec := newCompactBridgeTestContext(t, true)
  77. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  78. defer stop()
  79. waitForKeepaliveBeats()
  80. finalResponse := []byte(`{"id":"resp_ka_1","output":[{"id":"cmp_ka","type":"compaction","encrypted_content":"x"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)
  81. require.True(t, writeOpenAICompactSSEBridge(c, http.StatusOK, finalResponse))
  82. require.Equal(t, http.StatusOK, rec.Code)
  83. events := parseCompactBridgeSSE(t, stripKeepaliveComments(rec.Body.String()))
  84. require.Len(t, events, 2)
  85. require.Equal(t, "response.output_item.done", events[0][0])
  86. require.Equal(t, "compaction", gjson.Get(events[0][1], "item.type").String())
  87. require.Equal(t, "response.completed", events[1][0])
  88. require.Equal(t, "resp_ka_1", gjson.Get(events[1][1], "response.id").String())
  89. }
  90. // 心跳已提交后上游非 2xx:状态码无法回传,必须以 response.failed 终止事件
  91. // 收尾(Codex 将其作为终止事件处理),并标记流内错误供 ops 采集。
  92. func TestWriteOpenAICompactSSEBridge_AfterKeepaliveCommitFailureEmitsFailedEvent(t *testing.T) {
  93. c, rec := newCompactBridgeTestContext(t, true)
  94. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  95. defer stop()
  96. waitForKeepaliveBeats()
  97. require.True(t, writeOpenAICompactSSEBridge(c, http.StatusBadGateway, []byte(`{"error":{"message":"upstream exploded"}}`)))
  98. events := parseCompactBridgeSSE(t, stripKeepaliveComments(rec.Body.String()))
  99. require.Len(t, events, 1)
  100. require.Equal(t, "response.failed", events[0][0])
  101. require.Equal(t, "failed", gjson.Get(events[0][1], "response.status").String())
  102. require.Contains(t, gjson.Get(events[0][1], "response.error.message").String(), "upstream exploded")
  103. require.NotEmpty(t, gjson.Get(events[0][1], "response.id").String())
  104. streamErr, ok := GetOpsStreamError(c)
  105. require.True(t, ok)
  106. require.Equal(t, http.StatusBadGateway, streamErr.IntendedStatus)
  107. }
  108. // 心跳未提交时非 2xx 行为不变:返回 false,调用方按原 JSON+状态码写回。
  109. func TestWriteOpenAICompactSSEBridge_BeforeKeepaliveCommitFailureKeepsJSONPath(t *testing.T) {
  110. c, rec := newCompactBridgeTestContext(t, true)
  111. stop := StartOpenAICompactSSEKeepalive(c, time.Hour)
  112. stop()
  113. require.False(t, writeOpenAICompactSSEBridge(c, http.StatusBadGateway, []byte(`{"error":{"message":"fast fail"}}`)))
  114. require.Zero(t, rec.Body.Len())
  115. }
  116. // 未被显式拦截的写回路径(直接操作 c.Writer)也必须与心跳互斥:包装器在
  117. // 请求侧任何响应构造时停拍。-race 下验证无数据竞争,且停拍后不再有心跳
  118. // 字节写出。
  119. func TestOpenAICompactKeepaliveWriter_RequestSideWriteSuspendsBeats(t *testing.T) {
  120. c, rec := newCompactBridgeTestContext(t, true)
  121. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  122. defer stop()
  123. waitForKeepaliveBeats()
  124. // 模拟未拦截路径的直接写回(如 Forward 内部本地拒绝的 c.JSON)。
  125. _, err := c.Writer.Write([]byte(`{"error":"local reject"}`))
  126. require.NoError(t, err)
  127. lenAfterWrite := rec.Body.Len()
  128. waitForKeepaliveBeats()
  129. require.Equal(t, lenAfterWrite, rec.Body.Len(), "请求侧写回后心跳必须停止")
  130. require.Contains(t, rec.Body.String(), ": keepalive\n\n")
  131. require.Contains(t, rec.Body.String(), `{"error":"local reject"}`)
  132. }
  133. func TestOpenAICompactKeepaliveWriter_NilInnerWriter_NoPanic(t *testing.T) {
  134. w := &openAICompactKeepaliveWriter{
  135. k: &openAICompactSSEKeepalive{stop: make(chan struct{})},
  136. }
  137. w.ResponseWriter = nil
  138. assert.NotPanics(t, func() {
  139. assert.Equal(t, 0, w.Status())
  140. })
  141. assert.NotPanics(t, func() {
  142. assert.Equal(t, 0, w.Size())
  143. })
  144. assert.NotPanics(t, func() {
  145. assert.False(t, w.Written())
  146. })
  147. assert.NotPanics(t, func() {
  148. assert.NotNil(t, w.Header())
  149. })
  150. assert.NotPanics(t, func() {
  151. n, err := w.Write([]byte("test"))
  152. assert.Equal(t, 0, n)
  153. assert.NoError(t, err)
  154. })
  155. assert.NotPanics(t, func() {
  156. n, err := w.WriteString("test")
  157. assert.Equal(t, 0, n)
  158. assert.NoError(t, err)
  159. })
  160. assert.NotPanics(t, func() {
  161. w.WriteHeader(http.StatusOK)
  162. })
  163. assert.NotPanics(t, func() {
  164. w.WriteHeaderNow()
  165. })
  166. assert.NotPanics(t, func() {
  167. w.Flush()
  168. })
  169. assert.NotPanics(t, func() {
  170. conn, rw, err := w.Hijack()
  171. assert.Nil(t, conn)
  172. assert.Nil(t, rw)
  173. assert.Error(t, err)
  174. })
  175. assert.NotPanics(t, func() {
  176. ch := w.CloseNotify()
  177. assert.NotNil(t, ch)
  178. })
  179. assert.NotPanics(t, func() {
  180. assert.Nil(t, w.Pusher())
  181. })
  182. }
  183. func TestOpenAICompactKeepaliveWriter_NilKeepalive_NoPanic(t *testing.T) {
  184. c, rec := newCompactBridgeTestContext(t, true)
  185. w := &openAICompactKeepaliveWriter{ResponseWriter: c.Writer}
  186. assert.NotPanics(t, func() {
  187. assert.Equal(t, 0, w.Status())
  188. })
  189. assert.NotPanics(t, func() {
  190. assert.Equal(t, 0, w.Size())
  191. })
  192. assert.NotPanics(t, func() {
  193. assert.False(t, w.Written())
  194. })
  195. assert.NotPanics(t, func() {
  196. w.Header().Set("X-Test", "ok")
  197. })
  198. assert.NotPanics(t, func() {
  199. w.WriteHeader(http.StatusAccepted)
  200. })
  201. assert.NotPanics(t, func() {
  202. n, err := w.WriteString("ok")
  203. assert.Equal(t, 2, n)
  204. assert.NoError(t, err)
  205. })
  206. assert.NotPanics(t, func() {
  207. w.Flush()
  208. })
  209. require.Equal(t, "ok", rec.Header().Get("X-Test"))
  210. require.Equal(t, "ok", rec.Body.String())
  211. }
  212. func TestOpenAICompactKeepaliveWriter_DelegatesWhenReady(t *testing.T) {
  213. c, rec := newCompactBridgeTestContext(t, true)
  214. stop := StartOpenAICompactSSEKeepalive(c, time.Hour)
  215. defer stop()
  216. w, ok := c.Writer.(*openAICompactKeepaliveWriter)
  217. require.True(t, ok)
  218. w.Header().Set("X-Test", "ok")
  219. w.WriteHeader(http.StatusAccepted)
  220. n, err := w.WriteString("ready")
  221. require.NoError(t, err)
  222. require.Equal(t, len("ready"), n)
  223. require.Equal(t, http.StatusAccepted, w.Status())
  224. require.Equal(t, len("ready"), w.Size())
  225. require.True(t, w.Written())
  226. require.Equal(t, "ok", rec.Header().Get("X-Test"))
  227. require.Equal(t, "ready", rec.Body.String())
  228. }
  229. // fast policy block 在心跳提交后必须降级为 response.failed 终止事件。
  230. func TestWriteOpenAIFastPolicyBlockedResponse_AfterKeepaliveCommit(t *testing.T) {
  231. c, rec := newCompactBridgeTestContext(t, true)
  232. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  233. defer stop()
  234. waitForKeepaliveBeats()
  235. writeOpenAIFastPolicyBlockedResponse(c, &OpenAIFastBlockedError{Message: "tier blocked"})
  236. require.Equal(t, http.StatusOK, rec.Code)
  237. events := parseCompactBridgeSSE(t, stripKeepaliveComments(rec.Body.String()))
  238. require.Len(t, events, 1)
  239. require.Equal(t, "response.failed", events[0][0])
  240. require.Equal(t, "permission_error", gjson.Get(events[0][1], "response.error.code").String())
  241. require.Contains(t, gjson.Get(events[0][1], "response.error.message").String(), "tier blocked")
  242. }
  243. // failover"是否已写响应"判定的口径:心跳字节必须被排除,否则 compact 在
  244. // 上游等待期间发过心跳后,可换号的 failover 会被误判放弃;真实响应字节
  245. // 写出后口径必须变化。
  246. func TestOpenAICompactKeepaliveAdjustedWrittenSize_ExcludesHeartbeatBytes(t *testing.T) {
  247. c, rec := newCompactBridgeTestContext(t, true)
  248. // 无心跳的请求:等价于 c.Writer.Size()。
  249. require.Equal(t, c.Writer.Size(), OpenAICompactKeepaliveAdjustedWrittenSize(c))
  250. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  251. defer stop()
  252. before := OpenAICompactKeepaliveAdjustedWrittenSize(c)
  253. waitForKeepaliveBeats()
  254. require.Equal(t, before, OpenAICompactKeepaliveAdjustedWrittenSize(c), "仅心跳字节不得改变判定口径")
  255. // 真实响应字节写出(经包装器,先停拍再写)后口径必须变化。
  256. _, err := c.Writer.Write([]byte("real-bytes"))
  257. require.NoError(t, err)
  258. require.Equal(t, len("real-bytes"), OpenAICompactKeepaliveAdjustedWrittenSize(c))
  259. require.Contains(t, rec.Body.String(), ": keepalive\n\n")
  260. }
  261. func TestOpenAIStreamClientOutputStarted_IgnoresCompactKeepaliveBytes(t *testing.T) {
  262. c, _ := newCompactBridgeTestContext(t, true)
  263. stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
  264. defer stop()
  265. waitForKeepaliveBeats()
  266. require.True(t, c.Writer.Written())
  267. require.False(t, openAIStreamClientOutputStarted(c, false), "keepalive comments are not semantic output")
  268. _, err := c.Writer.Write([]byte("real-output"))
  269. require.NoError(t, err)
  270. require.True(t, openAIStreamClientOutputStarted(c, false))
  271. }
  272. // fast policy block 在心跳未提交时保持 403 JSON 原语义。
  273. func TestWriteOpenAIFastPolicyBlockedResponse_BeforeKeepaliveCommit(t *testing.T) {
  274. c, rec := newCompactBridgeTestContext(t, true)
  275. stop := StartOpenAICompactSSEKeepalive(c, time.Hour)
  276. defer stop()
  277. writeOpenAIFastPolicyBlockedResponse(c, &OpenAIFastBlockedError{Message: "tier blocked"})
  278. require.Equal(t, http.StatusForbidden, rec.Code)
  279. require.Equal(t, "permission_error", gjson.Get(rec.Body.String(), "error.type").String())
  280. }