openai_ws_passthrough_turn_pricing_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "sync"
  8. "testing"
  9. "time"
  10. coderws "github.com/coder/websocket"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. // startPassthroughHookRecordingServer 与 startPassthroughLifecycleServer 相同,
  16. // 但把一组会记录调用的 hooks 传给 ingress,用于观察透传路径的 turn 回调。
  17. func startPassthroughHookRecordingServer(
  18. t *testing.T,
  19. controlCtx context.Context,
  20. svc *OpenAIGatewayService,
  21. account *Account,
  22. hooks *OpenAIWSIngressHooks,
  23. ) (*httptest.Server, <-chan error) {
  24. t.Helper()
  25. serverErr := make(chan error, 1)
  26. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  27. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
  28. if err != nil {
  29. serverErr <- err
  30. return
  31. }
  32. defer func() { _ = conn.CloseNow() }()
  33. msgType, firstMessage, err := ReadOpenAIWSClientMessage(
  34. controlCtx,
  35. conn,
  36. 3*time.Second,
  37. coderws.StatusPolicyViolation,
  38. "missing first response.create message",
  39. )
  40. if err != nil {
  41. serverErr <- err
  42. return
  43. }
  44. if msgType != coderws.MessageText {
  45. serverErr <- errors.New("first message was not text")
  46. return
  47. }
  48. recorder := httptest.NewRecorder()
  49. ginCtx, _ := gin.CreateTestContext(recorder)
  50. req := r.Clone(controlCtx)
  51. req.Header = req.Header.Clone()
  52. ginCtx.Request = req
  53. serverErr <- svc.ProxyResponsesWebSocketFromClient(controlCtx, ginCtx, conn, account, "sk-test", firstMessage, hooks)
  54. }))
  55. return server, serverErr
  56. }
  57. func TestPassthroughIngressFollowUpCallsBeforeTurnAfterBeforeRequest(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. controlCtx, cancelControl := context.WithCancelCause(context.Background())
  60. defer cancelControl(context.Canceled)
  61. upstream := newStagedPassthroughConn()
  62. upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  63. var hooksMu sync.Mutex
  64. var callbacks []string
  65. afterTurnCalls := 0
  66. hooks := &OpenAIWSIngressHooks{
  67. BeforeRequest: func(int, []byte, string) error {
  68. hooksMu.Lock()
  69. callbacks = append(callbacks, "before_request")
  70. hooksMu.Unlock()
  71. return nil
  72. },
  73. BeforeTurn: func(int) error {
  74. hooksMu.Lock()
  75. callbacks = append(callbacks, "before_turn")
  76. hooksMu.Unlock()
  77. return nil
  78. },
  79. AfterTurn: func(int, *OpenAIForwardResult, error) {
  80. hooksMu.Lock()
  81. afterTurnCalls++
  82. hooksMu.Unlock()
  83. },
  84. }
  85. server, _ := startPassthroughHookRecordingServer(
  86. t,
  87. controlCtx,
  88. newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream),
  89. passthroughLifecycleAccount(),
  90. hooks,
  91. )
  92. defer server.Close()
  93. clientConn := dialPassthroughLifecycleClient(t, server)
  94. defer func() { _ = clientConn.CloseNow() }()
  95. require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String())
  96. event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
  97. require.NoError(t, err)
  98. require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
  99. writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second)
  100. err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`))
  101. cancelWrite()
  102. require.NoError(t, err)
  103. require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String())
  104. upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  105. event, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
  106. require.NoError(t, err)
  107. require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
  108. hooksMu.Lock()
  109. gotCallbacks := append([]string(nil), callbacks...)
  110. gotAfter := afterTurnCalls
  111. hooksMu.Unlock()
  112. require.Equal(t, []string{"before_request", "before_turn"}, gotCallbacks)
  113. require.Equal(t, 2, gotAfter)
  114. }
  115. func TestPassthroughIngressBeforeTurnRejectionDoesNotForwardFollowUp(t *testing.T) {
  116. gin.SetMode(gin.TestMode)
  117. controlCtx, cancelControl := context.WithCancelCause(context.Background())
  118. defer cancelControl(context.Canceled)
  119. upstream := newStagedPassthroughConn()
  120. upstream.Send(`{"type":"response.completed","response":{"id":"resp_reject_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  121. rejection := errors.New("turn rejected")
  122. var hooksMu sync.Mutex
  123. afterTurnCalls := 0
  124. var finalErr error
  125. beforeTurnTurn := 0
  126. hooks := &OpenAIWSIngressHooks{
  127. BeforeTurn: func(turn int) error {
  128. hooksMu.Lock()
  129. beforeTurnTurn = turn
  130. hooksMu.Unlock()
  131. return rejection
  132. },
  133. AfterTurn: func(_ int, _ *OpenAIForwardResult, turnErr error) {
  134. hooksMu.Lock()
  135. afterTurnCalls++
  136. if turnErr != nil {
  137. finalErr = turnErr
  138. }
  139. hooksMu.Unlock()
  140. },
  141. }
  142. server, serverErr := startPassthroughHookRecordingServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), hooks)
  143. defer server.Close()
  144. clientConn := dialPassthroughLifecycleClient(t, server)
  145. defer func() { _ = clientConn.CloseNow() }()
  146. requirePassthroughUpstreamWrite(t, upstream, time.Second)
  147. _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
  148. require.NoError(t, err)
  149. writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second)
  150. err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`))
  151. cancelWrite()
  152. require.NoError(t, err)
  153. select {
  154. case payload := <-upstream.writes:
  155. t.Fatalf("rejected response.create was forwarded upstream: %s", payload)
  156. case <-time.After(100 * time.Millisecond):
  157. }
  158. select {
  159. case err = <-serverErr:
  160. require.ErrorIs(t, err, rejection)
  161. case <-time.After(3 * time.Second):
  162. t.Fatal("passthrough ingress did not exit after BeforeTurn rejection")
  163. }
  164. hooksMu.Lock()
  165. gotBeforeTurn, gotAfter, gotFinalErr := beforeTurnTurn, afterTurnCalls, finalErr
  166. hooksMu.Unlock()
  167. require.Equal(t, 2, gotBeforeTurn)
  168. require.Equal(t, 2, gotAfter, "each started turn must be finalized exactly once")
  169. require.ErrorIs(t, gotFinalErr, rejection)
  170. }
  171. func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) {
  172. testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageText)
  173. }
  174. func TestPassthroughIngressFreezesBinarySubsequentTurnBeforeRequestPolicy(t *testing.T) {
  175. testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageBinary)
  176. }
  177. func testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T, secondMessageType coderws.MessageType) {
  178. t.Helper()
  179. gin.SetMode(gin.TestMode)
  180. controlCtx, cancelControl := context.WithCancelCause(context.Background())
  181. defer cancelControl(context.Canceled)
  182. upstream := newStagedPassthroughConn()
  183. upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  184. type turnStart struct {
  185. turn int
  186. startedAt time.Time
  187. }
  188. turnStarts := make(chan turnStart, 2)
  189. beforeRequestEntered := make(chan time.Time, 1)
  190. releaseBeforeRequest := make(chan struct{})
  191. hooks := &OpenAIWSIngressHooks{
  192. InitialTurnStartedAt: time.Now(),
  193. TurnStarted: func(turn int, startedAt time.Time) {
  194. turnStarts <- turnStart{turn: turn, startedAt: startedAt}
  195. },
  196. BeforeRequest: func(turn int, _ []byte, _ string) error {
  197. if turn == 2 {
  198. beforeRequestEntered <- time.Now()
  199. <-releaseBeforeRequest
  200. }
  201. return nil
  202. },
  203. }
  204. server, serverErr := startPassthroughHookRecordingServer(
  205. t,
  206. controlCtx,
  207. newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream),
  208. passthroughLifecycleAccount(),
  209. hooks,
  210. )
  211. defer server.Close()
  212. clientConn := dialPassthroughLifecycleClient(t, server)
  213. defer func() { _ = clientConn.CloseNow() }()
  214. require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String())
  215. firstCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
  216. require.NoError(t, err)
  217. require.Equal(t, "resp_first", gjson.GetBytes(firstCompleted, "response.id").String())
  218. select {
  219. case first := <-turnStarts:
  220. require.Equal(t, 1, first.turn)
  221. case <-time.After(time.Second):
  222. t.Fatal("first turn start was not reported")
  223. }
  224. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  225. err = clientConn.Write(writeCtx, secondMessageType, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_first"}`))
  226. cancelWrite()
  227. require.NoError(t, err)
  228. var policyEnteredAt time.Time
  229. select {
  230. case policyEnteredAt = <-beforeRequestEntered:
  231. case <-time.After(time.Second):
  232. t.Fatal("second turn did not enter BeforeRequest")
  233. }
  234. close(releaseBeforeRequest)
  235. require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String())
  236. upstream.Send(`{"type":"response.completed","response":{"id":"resp_second","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
  237. secondCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
  238. require.NoError(t, err)
  239. require.Equal(t, "resp_second", gjson.GetBytes(secondCompleted, "response.id").String())
  240. select {
  241. case second := <-turnStarts:
  242. require.Equal(t, 2, second.turn)
  243. require.False(t, second.startedAt.After(policyEnteredAt), "第二轮开始时刻必须在 BeforeRequest 策略执行前冻结")
  244. case <-time.After(time.Second):
  245. t.Fatal("second turn start was not reported")
  246. }
  247. _ = clientConn.CloseNow()
  248. cancelControl(context.Canceled)
  249. select {
  250. case <-serverErr:
  251. case <-time.After(3 * time.Second):
  252. t.Fatal("passthrough ingress did not exit")
  253. }
  254. }