passthrough_relay_test.go 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480
  1. package openai_ws_v2
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "strings"
  7. "sync"
  8. "sync/atomic"
  9. "testing"
  10. "time"
  11. coderws "github.com/coder/websocket"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type passthroughTestFrame struct {
  15. msgType coderws.MessageType
  16. payload []byte
  17. }
  18. type passthroughTestFrameConn struct {
  19. mu sync.Mutex
  20. writes []passthroughTestFrame
  21. readCh chan passthroughTestFrame
  22. once sync.Once
  23. }
  24. type delayedReadFrameConn struct {
  25. base FrameConn
  26. firstDelay time.Duration
  27. once sync.Once
  28. }
  29. type readStartSpyFrameConn struct {
  30. base FrameConn
  31. started chan struct{}
  32. startOnce sync.Once
  33. }
  34. type closeSpyFrameConn struct {
  35. closeCalls atomic.Int32
  36. }
  37. type eofReplacementFrameConn struct {
  38. FrameConn
  39. err error
  40. }
  41. type cancelJoinProbeFrameConn struct {
  42. readStarted chan struct{}
  43. readCanceled chan struct{}
  44. allowReturn chan struct{}
  45. readReturned chan struct{}
  46. startOnce sync.Once
  47. cancelOnce sync.Once
  48. returnOnce sync.Once
  49. }
  50. func newPassthroughTestFrameConn(frames []passthroughTestFrame, autoClose bool) *passthroughTestFrameConn {
  51. c := &passthroughTestFrameConn{
  52. readCh: make(chan passthroughTestFrame, len(frames)+1),
  53. }
  54. for _, frame := range frames {
  55. copied := passthroughTestFrame{msgType: frame.msgType, payload: append([]byte(nil), frame.payload...)}
  56. c.readCh <- copied
  57. }
  58. if autoClose {
  59. close(c.readCh)
  60. }
  61. return c
  62. }
  63. func (c *eofReplacementFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  64. msgType, payload, err := c.FrameConn.ReadFrame(ctx)
  65. if errors.Is(err, io.EOF) {
  66. return msgType, payload, c.err
  67. }
  68. return msgType, payload, err
  69. }
  70. func (c *passthroughTestFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  71. if ctx == nil {
  72. ctx = context.Background()
  73. }
  74. select {
  75. case <-ctx.Done():
  76. return coderws.MessageText, nil, ctx.Err()
  77. case frame, ok := <-c.readCh:
  78. if !ok {
  79. return coderws.MessageText, nil, io.EOF
  80. }
  81. return frame.msgType, append([]byte(nil), frame.payload...), nil
  82. }
  83. }
  84. func (c *passthroughTestFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
  85. if ctx == nil {
  86. ctx = context.Background()
  87. }
  88. select {
  89. case <-ctx.Done():
  90. return ctx.Err()
  91. default:
  92. }
  93. c.mu.Lock()
  94. defer c.mu.Unlock()
  95. c.writes = append(c.writes, passthroughTestFrame{msgType: msgType, payload: append([]byte(nil), payload...)})
  96. return nil
  97. }
  98. func (c *passthroughTestFrameConn) Close() error {
  99. c.once.Do(func() {
  100. defer func() { _ = recover() }()
  101. close(c.readCh)
  102. })
  103. return nil
  104. }
  105. func (c *passthroughTestFrameConn) Writes() []passthroughTestFrame {
  106. c.mu.Lock()
  107. defer c.mu.Unlock()
  108. out := make([]passthroughTestFrame, len(c.writes))
  109. copy(out, c.writes)
  110. return out
  111. }
  112. func (c *delayedReadFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  113. if c == nil || c.base == nil {
  114. return coderws.MessageText, nil, io.EOF
  115. }
  116. c.once.Do(func() {
  117. if c.firstDelay > 0 {
  118. timer := time.NewTimer(c.firstDelay)
  119. defer timer.Stop()
  120. select {
  121. case <-ctx.Done():
  122. case <-timer.C:
  123. }
  124. }
  125. })
  126. return c.base.ReadFrame(ctx)
  127. }
  128. func (c *delayedReadFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
  129. if c == nil || c.base == nil {
  130. return io.EOF
  131. }
  132. return c.base.WriteFrame(ctx, msgType, payload)
  133. }
  134. func (c *delayedReadFrameConn) Close() error {
  135. if c == nil || c.base == nil {
  136. return nil
  137. }
  138. return c.base.Close()
  139. }
  140. func (c *readStartSpyFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  141. c.startOnce.Do(func() { close(c.started) })
  142. return c.base.ReadFrame(ctx)
  143. }
  144. func (c *readStartSpyFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
  145. return c.base.WriteFrame(ctx, msgType, payload)
  146. }
  147. func (c *readStartSpyFrameConn) Close() error {
  148. return c.base.Close()
  149. }
  150. func (c *closeSpyFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  151. if ctx == nil {
  152. ctx = context.Background()
  153. }
  154. <-ctx.Done()
  155. return coderws.MessageText, nil, ctx.Err()
  156. }
  157. func (c *closeSpyFrameConn) WriteFrame(ctx context.Context, _ coderws.MessageType, _ []byte) error {
  158. if ctx == nil {
  159. ctx = context.Background()
  160. }
  161. select {
  162. case <-ctx.Done():
  163. return ctx.Err()
  164. default:
  165. return nil
  166. }
  167. }
  168. func (c *closeSpyFrameConn) Close() error {
  169. if c != nil {
  170. c.closeCalls.Add(1)
  171. }
  172. return nil
  173. }
  174. func (c *closeSpyFrameConn) CloseCalls() int32 {
  175. if c == nil {
  176. return 0
  177. }
  178. return c.closeCalls.Load()
  179. }
  180. func newCancelJoinProbeFrameConn() *cancelJoinProbeFrameConn {
  181. return &cancelJoinProbeFrameConn{
  182. readStarted: make(chan struct{}),
  183. readCanceled: make(chan struct{}),
  184. allowReturn: make(chan struct{}),
  185. readReturned: make(chan struct{}),
  186. }
  187. }
  188. func (c *cancelJoinProbeFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  189. c.startOnce.Do(func() { close(c.readStarted) })
  190. <-ctx.Done()
  191. c.cancelOnce.Do(func() { close(c.readCanceled) })
  192. <-c.allowReturn
  193. c.returnOnce.Do(func() { close(c.readReturned) })
  194. return coderws.MessageText, nil, ctx.Err()
  195. }
  196. func (c *cancelJoinProbeFrameConn) WriteFrame(ctx context.Context, _ coderws.MessageType, _ []byte) error {
  197. if ctx == nil {
  198. ctx = context.Background()
  199. }
  200. select {
  201. case <-ctx.Done():
  202. return ctx.Err()
  203. default:
  204. return nil
  205. }
  206. }
  207. func (c *cancelJoinProbeFrameConn) Close() error { return nil }
  208. func TestRelay_BasicRelayAndUsage(t *testing.T) {
  209. t.Parallel()
  210. clientConn := newPassthroughTestFrameConn(nil, false)
  211. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  212. {
  213. msgType: coderws.MessageText,
  214. payload: []byte(`{"type":"response.completed","response":{"id":"resp_123","usage":{"input_tokens":7,"output_tokens":3,"input_tokens_details":{"cached_tokens":2}}}}`),
  215. },
  216. }, true)
  217. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[{"type":"input_text","text":"hello"}]}`)
  218. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  219. defer cancel()
  220. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  221. require.Nil(t, relayExit)
  222. require.Equal(t, "gpt-5.3-codex", result.RequestModel)
  223. require.Equal(t, "resp_123", result.RequestID)
  224. require.Equal(t, "response.completed", result.TerminalEventType)
  225. require.Equal(t, 7, result.Usage.InputTokens)
  226. require.Equal(t, 3, result.Usage.OutputTokens)
  227. require.Equal(t, 2, result.Usage.CacheReadInputTokens)
  228. require.Nil(t, result.FirstTokenMs)
  229. require.Equal(t, int64(1), result.ClientToUpstreamFrames)
  230. require.Equal(t, int64(1), result.UpstreamToClientFrames)
  231. require.Equal(t, int64(0), result.DroppedDownstreamFrames)
  232. upstreamWrites := upstreamConn.Writes()
  233. require.Len(t, upstreamWrites, 1)
  234. require.Equal(t, coderws.MessageText, upstreamWrites[0].msgType)
  235. require.JSONEq(t, string(firstPayload), string(upstreamWrites[0].payload))
  236. clientWrites := clientConn.Writes()
  237. require.Len(t, clientWrites, 1)
  238. require.Equal(t, coderws.MessageText, clientWrites[0].msgType)
  239. require.JSONEq(t, `{"type":"response.completed","response":{"id":"resp_123","usage":{"input_tokens":7,"output_tokens":3,"input_tokens_details":{"cached_tokens":2}}}}`, string(clientWrites[0].payload))
  240. }
  241. func TestRelay_FunctionCallOutputBytesPreserved(t *testing.T) {
  242. t.Parallel()
  243. clientConn := newPassthroughTestFrameConn(nil, false)
  244. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  245. {
  246. msgType: coderws.MessageText,
  247. payload: []byte(`{"type":"response.completed","response":{"id":"resp_func","usage":{"input_tokens":1,"output_tokens":1}}}`),
  248. },
  249. }, true)
  250. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[{"type":"function_call_output","call_id":"call_abc123","output":"{\"ok\":true}"}]}`)
  251. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  252. defer cancel()
  253. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  254. require.Nil(t, relayExit)
  255. upstreamWrites := upstreamConn.Writes()
  256. require.Len(t, upstreamWrites, 1)
  257. require.Equal(t, coderws.MessageText, upstreamWrites[0].msgType)
  258. require.Equal(t, firstPayload, upstreamWrites[0].payload)
  259. }
  260. func TestRelay_UpstreamDisconnect(t *testing.T) {
  261. t.Parallel()
  262. // 上游立即关闭(EOF),客户端不发送额外帧
  263. clientConn := newPassthroughTestFrameConn(nil, false)
  264. upstreamConn := newPassthroughTestFrameConn(nil, true) // 立即 close -> EOF
  265. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  266. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  267. defer cancel()
  268. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  269. require.NotNil(t, relayExit)
  270. require.Equal(t, "read_upstream", relayExit.Stage)
  271. require.False(t, relayExit.Graceful)
  272. require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event")
  273. require.Equal(t, "gpt-4o", result.RequestModel)
  274. }
  275. func TestRelay_UpstreamNormalCloseBeforeResponseIDIsFailure(t *testing.T) {
  276. t.Parallel()
  277. clientConn := newPassthroughTestFrameConn(nil, false)
  278. upstreamConn := &eofReplacementFrameConn{
  279. FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{
  280. {
  281. msgType: coderws.MessageText,
  282. payload: []byte(`{"type":"response.created","status":"in_progress"}`),
  283. },
  284. {
  285. msgType: coderws.MessageText,
  286. payload: []byte(`{"type":"response.in_progress","status":"in_progress"}`),
  287. },
  288. }, true),
  289. err: coderws.CloseError{Code: coderws.StatusNormalClosure},
  290. }
  291. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
  292. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  293. defer cancel()
  294. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  295. require.NotNil(t, relayExit)
  296. require.Equal(t, "read_upstream", relayExit.Stage)
  297. require.False(t, relayExit.Graceful)
  298. require.True(t, relayExit.WroteDownstream)
  299. require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event")
  300. require.Empty(t, result.RequestID)
  301. require.Empty(t, result.TerminalEventType)
  302. require.Len(t, clientConn.Writes(), 2)
  303. }
  304. func TestRelay_UpstreamNormalCloseBeforeTerminalIsFailure(t *testing.T) {
  305. t.Parallel()
  306. clientConn := newPassthroughTestFrameConn(nil, false)
  307. upstreamConn := &eofReplacementFrameConn{
  308. FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{
  309. {
  310. msgType: coderws.MessageText,
  311. payload: []byte(`{"type":"response.created","response":{"id":"resp_incomplete","status":"in_progress"}}`),
  312. },
  313. {
  314. msgType: coderws.MessageText,
  315. payload: []byte(`{"type":"response.in_progress","response":{"id":"resp_incomplete","status":"in_progress"}}`),
  316. },
  317. }, true),
  318. err: coderws.CloseError{Code: coderws.StatusNormalClosure},
  319. }
  320. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
  321. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  322. defer cancel()
  323. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  324. require.NotNil(t, relayExit)
  325. require.Equal(t, "read_upstream", relayExit.Stage)
  326. require.False(t, relayExit.Graceful)
  327. require.True(t, relayExit.WroteDownstream)
  328. require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event")
  329. require.Empty(t, result.TerminalEventType)
  330. require.Len(t, clientConn.Writes(), 2)
  331. }
  332. func TestRelay_ClientDisconnect(t *testing.T) {
  333. t.Parallel()
  334. // 客户端立即关闭(EOF),上游阻塞读取直到 context 取消
  335. clientConn := newPassthroughTestFrameConn(nil, true) // 立即 close -> EOF
  336. upstreamConn := newPassthroughTestFrameConn(nil, false)
  337. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  338. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  339. defer cancel()
  340. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  341. require.NotNil(t, relayExit, "客户端 EOF 应返回可观测的中断状态")
  342. require.Equal(t, "client_disconnected", relayExit.Stage)
  343. require.Equal(t, "gpt-4o", result.RequestModel)
  344. }
  345. func TestRelay_ClientDisconnect_DrainCapturesLateUsage(t *testing.T) {
  346. t.Parallel()
  347. clientConn := newPassthroughTestFrameConn(nil, true)
  348. upstreamBase := newPassthroughTestFrameConn([]passthroughTestFrame{
  349. {
  350. msgType: coderws.MessageText,
  351. payload: []byte(`{"type":"response.completed","response":{"id":"resp_drain","usage":{"input_tokens":6,"output_tokens":4,"input_tokens_details":{"cached_tokens":1}}}}`),
  352. },
  353. }, true)
  354. upstreamConn := &delayedReadFrameConn{
  355. base: upstreamBase,
  356. firstDelay: 80 * time.Millisecond,
  357. }
  358. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  359. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  360. defer cancel()
  361. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  362. UpstreamDrainTimeout: 400 * time.Millisecond,
  363. })
  364. require.NotNil(t, relayExit)
  365. require.Equal(t, "client_disconnected", relayExit.Stage)
  366. require.Equal(t, "resp_drain", result.RequestID)
  367. require.Equal(t, "response.completed", result.TerminalEventType)
  368. require.Equal(t, 6, result.Usage.InputTokens)
  369. require.Equal(t, 4, result.Usage.OutputTokens)
  370. require.Equal(t, 1, result.Usage.CacheReadInputTokens)
  371. require.Equal(t, int64(1), result.ClientToUpstreamFrames)
  372. require.Equal(t, int64(0), result.UpstreamToClientFrames)
  373. require.Equal(t, int64(1), result.DroppedDownstreamFrames)
  374. }
  375. func TestRelay_IdleTimeout(t *testing.T) {
  376. t.Parallel()
  377. // 客户端和上游都不发送帧,idle timeout 应触发
  378. clientConn := newPassthroughTestFrameConn(nil, false)
  379. upstreamConn := newPassthroughTestFrameConn(nil, false)
  380. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  381. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  382. defer cancel()
  383. // 使用快进时间来加速 idle timeout
  384. now := time.Now()
  385. callCount := 0
  386. nowFn := func() time.Time {
  387. callCount++
  388. // 前几次调用返回正常时间(初始化阶段),之后快进
  389. if callCount <= 5 {
  390. return now
  391. }
  392. return now.Add(time.Hour) // 快进到超时
  393. }
  394. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  395. IdleTimeout: 2 * time.Second,
  396. Now: nowFn,
  397. })
  398. require.NotNil(t, relayExit, "应因 idle timeout 退出")
  399. require.Equal(t, "idle_timeout", relayExit.Stage)
  400. require.Equal(t, "gpt-4o", result.RequestModel)
  401. }
  402. func TestRelay_IdleTimeoutDoesNotCloseClientOnError(t *testing.T) {
  403. t.Parallel()
  404. clientConn := &closeSpyFrameConn{}
  405. upstreamConn := &closeSpyFrameConn{}
  406. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  407. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  408. defer cancel()
  409. now := time.Now()
  410. callCount := 0
  411. nowFn := func() time.Time {
  412. callCount++
  413. if callCount <= 5 {
  414. return now
  415. }
  416. return now.Add(time.Hour)
  417. }
  418. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  419. IdleTimeout: 2 * time.Second,
  420. Now: nowFn,
  421. })
  422. require.NotNil(t, relayExit, "应因 idle timeout 退出")
  423. require.Equal(t, "idle_timeout", relayExit.Stage)
  424. require.Zero(t, clientConn.CloseCalls(), "错误路径不应提前关闭客户端连接,交给上层决定 close code")
  425. require.GreaterOrEqual(t, upstreamConn.CloseCalls(), int32(1))
  426. }
  427. func TestRelay_JoinsUpstreamReaderBeforeReturning(t *testing.T) {
  428. t.Parallel()
  429. clientConn := &closeSpyFrameConn{}
  430. upstreamConn := newCancelJoinProbeFrameConn()
  431. ctx, cancel := context.WithCancel(context.Background())
  432. resultCh := make(chan *RelayExit, 1)
  433. go func() {
  434. _, relayExit := Relay(
  435. ctx,
  436. clientConn,
  437. upstreamConn,
  438. []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`),
  439. RelayOptions{},
  440. )
  441. resultCh <- relayExit
  442. }()
  443. select {
  444. case <-upstreamConn.readStarted:
  445. case <-time.After(time.Second):
  446. t.Fatal("upstream reader did not start")
  447. }
  448. cancel()
  449. select {
  450. case <-upstreamConn.readCanceled:
  451. case <-time.After(time.Second):
  452. t.Fatal("upstream reader did not observe relay cancellation")
  453. }
  454. select {
  455. case <-resultCh:
  456. t.Fatal("Relay returned before the upstream reader exited")
  457. case <-time.After(50 * time.Millisecond):
  458. }
  459. close(upstreamConn.allowReturn)
  460. select {
  461. case relayExit := <-resultCh:
  462. require.NotNil(t, relayExit)
  463. require.ErrorIs(t, relayExit.Err, context.Canceled)
  464. case <-time.After(time.Second):
  465. t.Fatal("Relay did not return after the upstream reader exited")
  466. }
  467. select {
  468. case <-upstreamConn.readReturned:
  469. default:
  470. t.Fatal("Relay returned before the upstream reader completion signal")
  471. }
  472. require.Zero(t, clientConn.CloseCalls(), "错误路径不应提前关闭客户端连接")
  473. }
  474. func TestRelay_NilConnections(t *testing.T) {
  475. t.Parallel()
  476. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  477. ctx := context.Background()
  478. t.Run("nil client conn", func(t *testing.T) {
  479. upstreamConn := newPassthroughTestFrameConn(nil, true)
  480. _, relayExit := Relay(ctx, nil, upstreamConn, firstPayload, RelayOptions{})
  481. require.NotNil(t, relayExit)
  482. require.Equal(t, "relay_init", relayExit.Stage)
  483. require.Contains(t, relayExit.Err.Error(), "nil")
  484. })
  485. t.Run("nil upstream conn", func(t *testing.T) {
  486. clientConn := newPassthroughTestFrameConn(nil, true)
  487. _, relayExit := Relay(ctx, clientConn, nil, firstPayload, RelayOptions{})
  488. require.NotNil(t, relayExit)
  489. require.Equal(t, "relay_init", relayExit.Stage)
  490. require.Contains(t, relayExit.Err.Error(), "nil")
  491. })
  492. }
  493. func TestRelay_MultipleUpstreamMessages(t *testing.T) {
  494. t.Parallel()
  495. // 上游发送多个事件(delta + completed),验证多帧中继和 usage 聚合
  496. clientConn := newPassthroughTestFrameConn(nil, false)
  497. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  498. {
  499. msgType: coderws.MessageText,
  500. payload: []byte(`{"type":"response.output_text.delta","delta":"Hello"}`),
  501. },
  502. {
  503. msgType: coderws.MessageText,
  504. payload: []byte(`{"type":"response.output_text.delta","delta":" world"}`),
  505. },
  506. {
  507. msgType: coderws.MessageText,
  508. payload: []byte(`{"type":"response.completed","response":{"id":"resp_multi","usage":{"input_tokens":10,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}}}}`),
  509. },
  510. }, true)
  511. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[{"type":"input_text","text":"hi"}]}`)
  512. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  513. defer cancel()
  514. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  515. require.Nil(t, relayExit)
  516. require.Equal(t, "resp_multi", result.RequestID)
  517. require.Equal(t, "response.completed", result.TerminalEventType)
  518. require.Equal(t, 10, result.Usage.InputTokens)
  519. require.Equal(t, 5, result.Usage.OutputTokens)
  520. require.Equal(t, 3, result.Usage.CacheReadInputTokens)
  521. require.NotNil(t, result.FirstTokenMs)
  522. // 验证所有 3 个上游帧都转发给了客户端
  523. clientWrites := clientConn.Writes()
  524. require.Len(t, clientWrites, 3)
  525. }
  526. func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) {
  527. t.Parallel()
  528. clientConn := newPassthroughTestFrameConn(nil, false)
  529. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  530. {
  531. msgType: coderws.MessageText,
  532. payload: []byte(`{"type":"response.completed","response":{"id":"resp_turn_1","usage":{"input_tokens":2,"output_tokens":1}}}`),
  533. },
  534. {
  535. msgType: coderws.MessageText,
  536. payload: []byte(`{"type":"response.failed","response":{"id":"resp_turn_2","usage":{"input_tokens":3,"output_tokens":4}}}`),
  537. },
  538. }, true)
  539. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
  540. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  541. defer cancel()
  542. turns := make([]RelayTurnResult, 0, 2)
  543. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  544. OnTurnComplete: func(turn RelayTurnResult) {
  545. turns = append(turns, turn)
  546. },
  547. })
  548. require.Nil(t, relayExit)
  549. require.Len(t, turns, 2)
  550. require.Equal(t, "resp_turn_1", turns[0].RequestID)
  551. require.Equal(t, "response.completed", turns[0].TerminalEventType)
  552. require.Equal(t, 2, turns[0].Usage.InputTokens)
  553. require.Equal(t, 1, turns[0].Usage.OutputTokens)
  554. require.Equal(t, "resp_turn_2", turns[1].RequestID)
  555. require.Equal(t, "response.failed", turns[1].TerminalEventType)
  556. require.Equal(t, 3, turns[1].Usage.InputTokens)
  557. require.Equal(t, 4, turns[1].Usage.OutputTokens)
  558. require.Equal(t, 5, result.Usage.InputTokens)
  559. require.Equal(t, 5, result.Usage.OutputTokens)
  560. }
  561. func TestRelay_OnTurnComplete_BareErrorWithoutIDBeforeLaterCompleted(t *testing.T) {
  562. t.Parallel()
  563. clientConn := newPassthroughTestFrameConn(nil, false)
  564. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  565. {
  566. msgType: coderws.MessageText,
  567. payload: []byte(`{"type":"error","usage":{"input_tokens":5,"output_tokens":1},"error":{"message":"first turn failed"}}`),
  568. },
  569. {
  570. msgType: coderws.MessageText,
  571. payload: []byte(`{"type":"response.completed","response":{"id":"resp_next","usage":{"input_tokens":3,"output_tokens":2}}}`),
  572. },
  573. }, true)
  574. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  575. defer cancel()
  576. turns := make([]RelayTurnResult, 0, 2)
  577. result, relayExit := Relay(
  578. ctx,
  579. clientConn,
  580. upstreamConn,
  581. []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`),
  582. RelayOptions{OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }},
  583. )
  584. require.Nil(t, relayExit)
  585. require.Len(t, turns, 2)
  586. require.Equal(t, "error", turns[0].TerminalEventType)
  587. require.Empty(t, turns[0].RequestID)
  588. require.Equal(t, Usage{InputTokens: 5, OutputTokens: 1}, turns[0].Usage)
  589. require.Equal(t, "response.completed", turns[1].TerminalEventType)
  590. require.Equal(t, "resp_next", turns[1].RequestID)
  591. require.Equal(t, Usage{InputTokens: 3, OutputTokens: 2}, turns[1].Usage)
  592. require.Equal(t, Usage{InputTokens: 8, OutputTokens: 3}, result.Usage)
  593. }
  594. func TestRelay_OnTurnComplete_AuxiliaryFrameDoesNotSettleBareErrorBeforeFailed(t *testing.T) {
  595. t.Parallel()
  596. clientConn := newPassthroughTestFrameConn(nil, false)
  597. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  598. {msgType: coderws.MessageText, payload: []byte(`{"type":"error","error":{"code":"server_error","message":"failed"}}`)},
  599. {msgType: coderws.MessageText, payload: []byte(`{"type":"rate_limits.updated","rate_limits":[{"name":"requests","remaining":1}]}`)},
  600. {msgType: coderws.MessageText, payload: []byte(`{"type":"response.failed","response":{"id":"resp_authoritative","usage":{"input_tokens":7,"output_tokens":2},"error":{"code":"server_error","message":"failed"}}}`)},
  601. }, true)
  602. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  603. defer cancel()
  604. turns := make([]RelayTurnResult, 0, 1)
  605. result, relayExit := Relay(ctx, clientConn, upstreamConn, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`), RelayOptions{
  606. OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) },
  607. })
  608. require.Nil(t, relayExit)
  609. require.Len(t, turns, 1)
  610. require.Equal(t, "response.failed", turns[0].TerminalEventType)
  611. require.Equal(t, "resp_authoritative", turns[0].RequestID)
  612. require.Equal(t, Usage{InputTokens: 7, OutputTokens: 2}, turns[0].Usage)
  613. require.Equal(t, turns[0].Usage, result.Usage)
  614. }
  615. func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) {
  616. t.Parallel()
  617. clientConn := newPassthroughTestFrameConn(nil, false)
  618. upstreamConn := newPassthroughTestFrameConn(nil, false)
  619. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
  620. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  621. defer cancel()
  622. turns := make(chan RelayTurnResult, 2)
  623. done := make(chan struct{})
  624. go func() {
  625. defer close(done)
  626. _, _ = Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  627. OnTurnComplete: func(turn RelayTurnResult) {
  628. turns <- turn
  629. },
  630. })
  631. }()
  632. upstreamConn.readCh <- passthroughTestFrame{
  633. msgType: coderws.MessageText,
  634. payload: []byte(`{"type":"response.completed","response":{"id":"resp_sol","usage":{"input_tokens":2,"output_tokens":1}}}`),
  635. }
  636. firstTurn := <-turns
  637. require.Equal(t, "gpt-5.6-sol", firstTurn.RequestModel)
  638. clientConn.readCh <- passthroughTestFrame{
  639. msgType: coderws.MessageText,
  640. payload: []byte(`{"type":"response.create","model":"gpt-5.6-terra","input":[]}`),
  641. }
  642. require.Eventually(t, func() bool {
  643. return len(upstreamConn.Writes()) == 2
  644. }, time.Second, 10*time.Millisecond)
  645. upstreamConn.readCh <- passthroughTestFrame{
  646. msgType: coderws.MessageText,
  647. payload: []byte(`{"type":"response.completed","response":{"id":"resp_terra","usage":{"input_tokens":3,"output_tokens":1}}}`),
  648. }
  649. secondTurn := <-turns
  650. require.Equal(t, "gpt-5.6-terra", secondTurn.RequestModel)
  651. _ = clientConn.Close()
  652. _ = upstreamConn.Close()
  653. select {
  654. case <-done:
  655. case <-time.After(2 * time.Second):
  656. t.Fatal("relay did not stop after client close")
  657. }
  658. }
  659. func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) {
  660. t.Parallel()
  661. clientConn := newPassthroughTestFrameConn(nil, false)
  662. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  663. {
  664. msgType: coderws.MessageText,
  665. payload: []byte(`{"type":"response.output_text.delta","response_id":"resp_metric","delta":"hi"}`),
  666. },
  667. {
  668. msgType: coderws.MessageText,
  669. payload: []byte(`{"type":"response.completed","response":{"id":"resp_metric","usage":{"input_tokens":2,"output_tokens":1}}}`),
  670. },
  671. }, true)
  672. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
  673. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  674. defer cancel()
  675. base := time.Unix(0, 0)
  676. var nowTick atomic.Int64
  677. nowFn := func() time.Time {
  678. step := nowTick.Add(1)
  679. return base.Add(time.Duration(step) * 5 * time.Millisecond)
  680. }
  681. var turn RelayTurnResult
  682. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  683. Now: nowFn,
  684. OnTurnComplete: func(current RelayTurnResult) {
  685. turn = current
  686. },
  687. })
  688. require.Nil(t, relayExit)
  689. require.Equal(t, "resp_metric", turn.RequestID)
  690. require.Equal(t, "response.completed", turn.TerminalEventType)
  691. require.NotNil(t, turn.FirstTokenMs)
  692. require.GreaterOrEqual(t, *turn.FirstTokenMs, 0)
  693. require.Greater(t, turn.Duration.Milliseconds(), int64(0))
  694. require.NotNil(t, result.FirstTokenMs)
  695. require.Greater(t, result.Duration.Milliseconds(), int64(0))
  696. }
  697. func TestRelay_OnTurnComplete_UsesResponseCreateTimeAcrossPricingBoundary(t *testing.T) {
  698. t.Parallel()
  699. clientConn := newPassthroughTestFrameConn(nil, false)
  700. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  701. {
  702. msgType: coderws.MessageText,
  703. payload: []byte(`{"type":"response.completed","response":{"id":"resp_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`),
  704. },
  705. }, true)
  706. responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
  707. upstreamResponseAt := responseCreateAt.Add(time.Second)
  708. var nowCalls atomic.Int64
  709. nowFn := func() time.Time {
  710. if nowCalls.Add(1) == 1 {
  711. return responseCreateAt
  712. }
  713. return upstreamResponseAt
  714. }
  715. var turn RelayTurnResult
  716. _, relayExit := Relay(
  717. context.Background(),
  718. clientConn,
  719. upstreamConn,
  720. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  721. RelayOptions{
  722. Now: nowFn,
  723. OnTurnComplete: func(current RelayTurnResult) { turn = current },
  724. },
  725. )
  726. require.Nil(t, relayExit)
  727. require.Equal(t, responseCreateAt, turn.StartedAt)
  728. }
  729. func TestRelay_OnTurnComplete_UsesExplicitFirstTurnStartedAt(t *testing.T) {
  730. t.Parallel()
  731. clientConn := newPassthroughTestFrameConn(nil, false)
  732. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  733. {
  734. msgType: coderws.MessageText,
  735. payload: []byte(`{"type":"response.completed","response":{"id":"resp_initial_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`),
  736. },
  737. }, true)
  738. responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
  739. relayStartedAt := responseCreateAt.Add(time.Second)
  740. var turn RelayTurnResult
  741. _, relayExit := Relay(
  742. context.Background(),
  743. clientConn,
  744. upstreamConn,
  745. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  746. RelayOptions{
  747. FirstTurnStartedAt: responseCreateAt,
  748. Now: func() time.Time { return relayStartedAt },
  749. OnTurnComplete: func(current RelayTurnResult) { turn = current },
  750. },
  751. )
  752. require.Nil(t, relayExit)
  753. require.Equal(t, responseCreateAt, turn.StartedAt)
  754. }
  755. func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBoundary(t *testing.T) {
  756. t.Parallel()
  757. clientConn := newPassthroughTestFrameConn(nil, false)
  758. upstreamConn := newPassthroughTestFrameConn(nil, false)
  759. firstTurnAt := time.Date(2026, time.August, 17, 9, 0, 0, 0, time.UTC)
  760. secondTurnAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
  761. secondResponseAt := secondTurnAt.Add(time.Second)
  762. var clock atomic.Int64
  763. clock.Store(firstTurnAt.UnixNano())
  764. turns := make(chan RelayTurnResult, 2)
  765. ctx, cancel := context.WithCancel(context.Background())
  766. defer cancel()
  767. done := make(chan struct{})
  768. go func() {
  769. defer close(done)
  770. _, _ = Relay(
  771. ctx,
  772. clientConn,
  773. upstreamConn,
  774. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  775. RelayOptions{
  776. Now: func() time.Time { return time.Unix(0, clock.Load()).UTC() },
  777. OnTurnComplete: func(current RelayTurnResult) {
  778. turns <- current
  779. },
  780. },
  781. )
  782. }()
  783. require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond)
  784. upstreamConn.readCh <- passthroughTestFrame{
  785. msgType: coderws.MessageText,
  786. payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`),
  787. }
  788. select {
  789. case <-turns:
  790. case <-time.After(time.Second):
  791. t.Fatal("first turn did not complete")
  792. }
  793. clock.Store(secondTurnAt.UnixNano())
  794. clientConn.readCh <- passthroughTestFrame{
  795. msgType: coderws.MessageText,
  796. payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  797. }
  798. require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond)
  799. clock.Store(secondResponseAt.UnixNano())
  800. upstreamConn.readCh <- passthroughTestFrame{
  801. msgType: coderws.MessageText,
  802. payload: []byte(`{"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`),
  803. }
  804. var secondTurn RelayTurnResult
  805. select {
  806. case secondTurn = <-turns:
  807. case <-time.After(time.Second):
  808. t.Fatal("second turn did not complete")
  809. }
  810. require.Equal(t, secondTurnAt, secondTurn.StartedAt)
  811. cancel()
  812. select {
  813. case <-done:
  814. case <-time.After(time.Second):
  815. t.Fatal("relay did not stop after cancellation")
  816. }
  817. }
  818. func TestRelay_BeforeWriteClientTracksDownstreamPerTurn(t *testing.T) {
  819. t.Parallel()
  820. clientConn := newPassthroughTestFrameConn(nil, false)
  821. upstreamConn := newPassthroughTestFrameConn(nil, false)
  822. ctx, cancel := context.WithCancel(context.Background())
  823. defer cancel()
  824. wroteStates := make(chan bool, 3)
  825. done := make(chan *RelayExit, 1)
  826. stopErr := errors.New("stop after second-turn error")
  827. go func() {
  828. _, relayExit := Relay(
  829. ctx,
  830. clientConn,
  831. upstreamConn,
  832. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  833. RelayOptions{BeforeWriteClient: func(_ coderws.MessageType, payload []byte, wroteDownstream bool) error {
  834. wroteStates <- wroteDownstream
  835. if strings.Contains(string(payload), `"type":"error"`) {
  836. return stopErr
  837. }
  838. return nil
  839. }},
  840. )
  841. done <- relayExit
  842. }()
  843. require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond)
  844. upstreamConn.readCh <- passthroughTestFrame{
  845. msgType: coderws.MessageText,
  846. payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`),
  847. }
  848. require.False(t, <-wroteStates)
  849. require.Eventually(t, func() bool { return len(clientConn.Writes()) == 1 }, time.Second, time.Millisecond)
  850. clientConn.readCh <- passthroughTestFrame{
  851. msgType: coderws.MessageText,
  852. payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  853. }
  854. require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond)
  855. upstreamConn.readCh <- passthroughTestFrame{
  856. msgType: coderws.MessageText,
  857. payload: []byte(`{"type":"error","error":{"type":"usage_limit_reached"}}`),
  858. }
  859. require.False(t, <-wroteStates, "the next turn must not inherit the first turn's downstream write")
  860. select {
  861. case relayExit := <-done:
  862. require.NotNil(t, relayExit)
  863. require.ErrorIs(t, relayExit.Err, stopErr)
  864. require.True(t, relayExit.WroteDownstream, "connection-wide diagnostics must retain prior output")
  865. case <-time.After(time.Second):
  866. t.Fatal("relay did not stop after the rejected second-turn event")
  867. }
  868. }
  869. func TestRelay_BinaryFramePassthrough(t *testing.T) {
  870. t.Parallel()
  871. // 验证 binary frame 被透传但不进行 usage 解析
  872. binaryPayload := []byte{0x00, 0x01, 0x02, 0x03}
  873. clientConn := newPassthroughTestFrameConn(nil, false)
  874. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  875. {
  876. msgType: coderws.MessageBinary,
  877. payload: binaryPayload,
  878. },
  879. }, true)
  880. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  881. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  882. defer cancel()
  883. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  884. require.NotNil(t, relayExit)
  885. require.Equal(t, "read_upstream", relayExit.Stage)
  886. require.False(t, relayExit.Graceful)
  887. require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event")
  888. // binary frame 不解析 usage
  889. require.Equal(t, 0, result.Usage.InputTokens)
  890. clientWrites := clientConn.Writes()
  891. require.Len(t, clientWrites, 1)
  892. require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType)
  893. require.Equal(t, binaryPayload, clientWrites[0].payload)
  894. }
  895. func TestRelay_BinaryJSONFrameSkipsObservation(t *testing.T) {
  896. t.Parallel()
  897. clientConn := newPassthroughTestFrameConn(nil, false)
  898. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  899. {
  900. msgType: coderws.MessageBinary,
  901. payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary","usage":{"input_tokens":7,"output_tokens":3}}}`),
  902. },
  903. }, true)
  904. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  905. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  906. defer cancel()
  907. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  908. require.Nil(t, relayExit)
  909. require.Equal(t, 0, result.Usage.InputTokens)
  910. require.Equal(t, "", result.RequestID)
  911. require.Equal(t, "", result.TerminalEventType)
  912. clientWrites := clientConn.Writes()
  913. require.Len(t, clientWrites, 1)
  914. require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType)
  915. }
  916. func TestRelay_UpstreamErrorEventPassthroughRaw(t *testing.T) {
  917. t.Parallel()
  918. clientConn := newPassthroughTestFrameConn(nil, false)
  919. errorEvent := []byte(`{"type":"error","error":{"type":"invalid_request_error","message":"No tool call found"}}`)
  920. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  921. {
  922. msgType: coderws.MessageText,
  923. payload: errorEvent,
  924. },
  925. }, true)
  926. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  927. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  928. defer cancel()
  929. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  930. require.Nil(t, relayExit)
  931. clientWrites := clientConn.Writes()
  932. require.Len(t, clientWrites, 1)
  933. require.Equal(t, coderws.MessageText, clientWrites[0].msgType)
  934. require.Equal(t, errorEvent, clientWrites[0].payload)
  935. }
  936. func TestRelay_PreservesFirstMessageType(t *testing.T) {
  937. t.Parallel()
  938. clientConn := newPassthroughTestFrameConn(nil, false)
  939. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  940. {
  941. msgType: coderws.MessageBinary,
  942. payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary_first_type"}}`),
  943. },
  944. }, true)
  945. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  946. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  947. defer cancel()
  948. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  949. FirstMessageType: coderws.MessageBinary,
  950. })
  951. require.Nil(t, relayExit)
  952. upstreamWrites := upstreamConn.Writes()
  953. require.Len(t, upstreamWrites, 1)
  954. require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType)
  955. require.Equal(t, firstPayload, upstreamWrites[0].payload)
  956. clientWrites := clientConn.Writes()
  957. require.Len(t, clientWrites, 1)
  958. require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType)
  959. }
  960. func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) {
  961. baseline := SnapshotMetrics().UsageParseFailureTotal
  962. // 上游发送无效 JSON(非 usage 格式),不应影响透传
  963. clientConn := newPassthroughTestFrameConn(nil, false)
  964. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  965. {
  966. msgType: coderws.MessageText,
  967. payload: []byte(`{"type":"response.completed","response":{"id":"resp_bad","usage":"not_an_object"}}`),
  968. },
  969. }, true)
  970. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  971. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  972. defer cancel()
  973. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  974. require.Nil(t, relayExit)
  975. // usage 解析失败,值为 0 但不影响透传
  976. require.Equal(t, 0, result.Usage.InputTokens)
  977. require.Equal(t, "response.completed", result.TerminalEventType)
  978. // 帧仍然被转发
  979. clientWrites := clientConn.Writes()
  980. require.Len(t, clientWrites, 1)
  981. require.GreaterOrEqual(t, SnapshotMetrics().UsageParseFailureTotal, baseline+1)
  982. }
  983. func TestRelay_WriteUpstreamFirstMessageFails(t *testing.T) {
  984. t.Parallel()
  985. // 上游连接立即关闭,首包写入失败
  986. upstreamConn := newPassthroughTestFrameConn(nil, true)
  987. _ = upstreamConn.Close()
  988. // 覆盖 WriteFrame 使其返回错误
  989. errConn := &errorOnWriteFrameConn{}
  990. clientConn := newPassthroughTestFrameConn(nil, false)
  991. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  992. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  993. defer cancel()
  994. _, relayExit := Relay(ctx, clientConn, errConn, firstPayload, RelayOptions{})
  995. require.NotNil(t, relayExit)
  996. require.Equal(t, "write_upstream", relayExit.Stage)
  997. }
  998. func TestRelay_ContextCanceled(t *testing.T) {
  999. t.Parallel()
  1000. clientConn := newPassthroughTestFrameConn(nil, false)
  1001. upstreamConn := newPassthroughTestFrameConn(nil, false)
  1002. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  1003. // 立即取消 context
  1004. ctx, cancel := context.WithCancel(context.Background())
  1005. cancel()
  1006. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
  1007. // context 取消导致写首包失败
  1008. require.NotNil(t, relayExit)
  1009. }
  1010. func TestRelay_DownstreamPreambleStartsClientReader(t *testing.T) {
  1011. clientBase := newPassthroughTestFrameConn(nil, false)
  1012. clientConn := &readStartSpyFrameConn{base: clientBase, started: make(chan struct{})}
  1013. upstreamConn := newPassthroughTestFrameConn(nil, false)
  1014. resultCh := make(chan *RelayExit, 1)
  1015. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  1016. defer cancel()
  1017. go func() {
  1018. _, relayExit := Relay(
  1019. ctx,
  1020. clientConn,
  1021. upstreamConn,
  1022. []byte(`{"type":"response.create","model":"gpt-5.1"}`),
  1023. RelayOptions{
  1024. StartClientAfterFirstDownstream: true,
  1025. },
  1026. )
  1027. resultCh <- relayExit
  1028. }()
  1029. upstreamConn.readCh <- passthroughTestFrame{
  1030. msgType: coderws.MessageText,
  1031. payload: []byte(`{"type":"response.created","response":{"id":"resp_semantic_gate"}}`),
  1032. }
  1033. require.Eventually(t, func() bool { return len(clientBase.Writes()) == 1 }, time.Second, 10*time.Millisecond)
  1034. select {
  1035. case <-clientConn.started:
  1036. case <-time.After(time.Second):
  1037. t.Fatal("response.created did not start the client reader")
  1038. }
  1039. upstreamConn.readCh <- passthroughTestFrame{
  1040. msgType: coderws.MessageText,
  1041. payload: []byte(`{"type":"response.completed","response":{"id":"resp_semantic_gate","usage":{"input_tokens":1,"output_tokens":1}}}`),
  1042. }
  1043. _ = upstreamConn.Close()
  1044. select {
  1045. case relayExit := <-resultCh:
  1046. require.Nil(t, relayExit)
  1047. case <-time.After(time.Second):
  1048. t.Fatal("relay did not finish after terminal event and upstream close")
  1049. }
  1050. }
  1051. func TestRelay_TraceEvents_ContainsLifecycleStages(t *testing.T) {
  1052. t.Parallel()
  1053. clientConn := newPassthroughTestFrameConn(nil, false)
  1054. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  1055. {
  1056. msgType: coderws.MessageText,
  1057. payload: []byte(`{"type":"response.completed","response":{"id":"resp_trace","usage":{"input_tokens":1,"output_tokens":1}}}`),
  1058. },
  1059. }, true)
  1060. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  1061. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  1062. defer cancel()
  1063. stages := make([]string, 0, 8)
  1064. var stagesMu sync.Mutex
  1065. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  1066. OnTrace: func(event RelayTraceEvent) {
  1067. stagesMu.Lock()
  1068. stages = append(stages, event.Stage)
  1069. stagesMu.Unlock()
  1070. },
  1071. })
  1072. require.Nil(t, relayExit)
  1073. stagesMu.Lock()
  1074. capturedStages := append([]string(nil), stages...)
  1075. stagesMu.Unlock()
  1076. require.Contains(t, capturedStages, "relay_start")
  1077. require.Contains(t, capturedStages, "write_first_message_ok")
  1078. require.Contains(t, capturedStages, "first_exit")
  1079. require.Contains(t, capturedStages, "relay_complete")
  1080. }
  1081. func TestRelay_TraceEvents_IdleTimeout(t *testing.T) {
  1082. t.Parallel()
  1083. clientConn := newPassthroughTestFrameConn(nil, false)
  1084. upstreamConn := newPassthroughTestFrameConn(nil, false)
  1085. firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
  1086. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  1087. defer cancel()
  1088. now := time.Now()
  1089. callCount := 0
  1090. nowFn := func() time.Time {
  1091. callCount++
  1092. if callCount <= 5 {
  1093. return now
  1094. }
  1095. return now.Add(time.Hour)
  1096. }
  1097. stages := make([]string, 0, 8)
  1098. var stagesMu sync.Mutex
  1099. _, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  1100. IdleTimeout: 2 * time.Second,
  1101. Now: nowFn,
  1102. OnTrace: func(event RelayTraceEvent) {
  1103. stagesMu.Lock()
  1104. stages = append(stages, event.Stage)
  1105. stagesMu.Unlock()
  1106. },
  1107. })
  1108. require.NotNil(t, relayExit)
  1109. require.Equal(t, "idle_timeout", relayExit.Stage)
  1110. stagesMu.Lock()
  1111. capturedStages := append([]string(nil), stages...)
  1112. stagesMu.Unlock()
  1113. require.Contains(t, capturedStages, "idle_timeout_triggered")
  1114. require.Contains(t, capturedStages, "relay_exit")
  1115. }
  1116. // errorOnWriteFrameConn 是一个写入总是失败的 FrameConn 实现,用于测试首包写入失败。
  1117. type errorOnWriteFrameConn struct{}
  1118. func (c *errorOnWriteFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  1119. <-ctx.Done()
  1120. return coderws.MessageText, nil, ctx.Err()
  1121. }
  1122. func (c *errorOnWriteFrameConn) WriteFrame(_ context.Context, _ coderws.MessageType, _ []byte) error {
  1123. return errors.New("write failed: connection refused")
  1124. }
  1125. func (c *errorOnWriteFrameConn) Close() error {
  1126. return nil
  1127. }
  1128. func TestRelay_NoSemanticOutputTerminalSequence_FirstTokenMsNil(t *testing.T) {
  1129. t.Parallel()
  1130. for _, terminalEvent := range []string{"response.completed", "response.done"} {
  1131. terminalEvent := terminalEvent
  1132. t.Run(terminalEvent, func(t *testing.T) {
  1133. t.Parallel()
  1134. clientConn := newPassthroughTestFrameConn(nil, false)
  1135. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  1136. {
  1137. msgType: coderws.MessageText,
  1138. payload: []byte(`{"type":"response.created","response":{"id":"resp_no_output"}}`),
  1139. },
  1140. {
  1141. msgType: coderws.MessageText,
  1142. payload: []byte(`{"type":"response.in_progress","response":{"id":"resp_no_output"}}`),
  1143. },
  1144. {
  1145. msgType: coderws.MessageText,
  1146. payload: []byte(`{"type":"response.content_part.done","response_id":"resp_no_output"}`),
  1147. },
  1148. {
  1149. msgType: coderws.MessageText,
  1150. payload: []byte(`{"type":"response.output_item.done","response_id":"resp_no_output"}`),
  1151. },
  1152. {
  1153. msgType: coderws.MessageText,
  1154. payload: []byte(`{"type":"` + terminalEvent + `","response":{"id":"resp_no_output","usage":{"input_tokens":2,"output_tokens":0}}}`),
  1155. },
  1156. }, true)
  1157. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  1158. defer cancel()
  1159. var turn RelayTurnResult
  1160. result, relayExit := Relay(
  1161. ctx,
  1162. clientConn,
  1163. upstreamConn,
  1164. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  1165. RelayOptions{OnTurnComplete: func(current RelayTurnResult) { turn = current }},
  1166. )
  1167. require.Nil(t, relayExit)
  1168. require.Equal(t, terminalEvent, turn.TerminalEventType)
  1169. require.Nil(t, turn.FirstTokenMs)
  1170. require.Equal(t, terminalEvent, result.TerminalEventType)
  1171. require.Nil(t, result.FirstTokenMs)
  1172. require.Equal(t, int64(5), result.UpstreamToClientFrames)
  1173. })
  1174. }
  1175. }
  1176. func TestRelay_NoDeltaOutputDoneEvent_RecordsFirstTokenBeforeTerminal(t *testing.T) {
  1177. t.Parallel()
  1178. tests := []struct {
  1179. name string
  1180. donePayload string
  1181. }{
  1182. {
  1183. name: "output text done",
  1184. donePayload: `{"type":"response.output_text.done","response_id":"resp_done","text":"hello"}`,
  1185. },
  1186. {
  1187. name: "function call arguments done",
  1188. donePayload: `{"type":"response.function_call_arguments.done","response_id":"resp_done","arguments":"{\"city\":\"Paris\"}"}`,
  1189. },
  1190. }
  1191. for _, tt := range tests {
  1192. tt := tt
  1193. t.Run(tt.name, func(t *testing.T) {
  1194. t.Parallel()
  1195. clientConn := newPassthroughTestFrameConn(nil, false)
  1196. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  1197. {msgType: coderws.MessageText, payload: []byte(`{"type":"response.created","response":{"id":"resp_done"}}`)},
  1198. {msgType: coderws.MessageText, payload: []byte(tt.donePayload)},
  1199. {msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_done","usage":{"input_tokens":2,"output_tokens":1}}}`)},
  1200. }, true)
  1201. base := time.Unix(0, 0)
  1202. var nowTick atomic.Int64
  1203. nowFn := func() time.Time {
  1204. return base.Add(time.Duration(nowTick.Add(1)) * 10 * time.Millisecond)
  1205. }
  1206. var turn RelayTurnResult
  1207. result, relayExit := Relay(
  1208. context.Background(),
  1209. clientConn,
  1210. upstreamConn,
  1211. []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
  1212. RelayOptions{
  1213. Now: nowFn,
  1214. OnTurnComplete: func(current RelayTurnResult) { turn = current },
  1215. },
  1216. )
  1217. require.Nil(t, relayExit)
  1218. require.NotNil(t, turn.FirstTokenMs)
  1219. require.Less(t, int64(*turn.FirstTokenMs), turn.Duration.Milliseconds())
  1220. require.NotNil(t, result.FirstTokenMs)
  1221. require.Less(t, int64(*result.FirstTokenMs), result.Duration.Milliseconds())
  1222. })
  1223. }
  1224. }
  1225. func TestRelay_OnTurnComplete_RealOpenAIStream_FirstTokenMs(t *testing.T) {
  1226. t.Parallel()
  1227. clientConn := newPassthroughTestFrameConn(nil, false)
  1228. upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
  1229. {
  1230. msgType: coderws.MessageText,
  1231. payload: []byte(`{"type":"response.created","response":{"id":"resp_real"}}`),
  1232. },
  1233. {
  1234. msgType: coderws.MessageText,
  1235. payload: []byte(`{"type":"response.output_text.delta","delta":"He"}`),
  1236. },
  1237. {
  1238. msgType: coderws.MessageText,
  1239. payload: []byte(`{"type":"response.output_text.delta","delta":"llo"}`),
  1240. },
  1241. {
  1242. msgType: coderws.MessageText,
  1243. payload: []byte(`{"type":"response.output_text.delta","delta":" world"}`),
  1244. },
  1245. {
  1246. msgType: coderws.MessageText,
  1247. payload: []byte(`{"type":"response.completed","response":{"id":"resp_real","usage":{"input_tokens":2,"output_tokens":3}}}`),
  1248. },
  1249. }, true)
  1250. firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
  1251. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  1252. defer cancel()
  1253. base := time.Unix(0, 0)
  1254. var nowTick atomic.Int64
  1255. nowFn := func() time.Time {
  1256. step := nowTick.Add(1)
  1257. return base.Add(time.Duration(step) * 10 * time.Millisecond)
  1258. }
  1259. var turn RelayTurnResult
  1260. result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
  1261. Now: nowFn,
  1262. OnTurnComplete: func(current RelayTurnResult) {
  1263. turn = current
  1264. },
  1265. })
  1266. require.Nil(t, relayExit)
  1267. require.Equal(t, "resp_real", turn.RequestID)
  1268. require.Equal(t, "response.completed", turn.TerminalEventType)
  1269. require.NotNil(t, turn.FirstTokenMs, "per-turn FirstTokenMs must be captured for real OpenAI streams")
  1270. require.Greater(t, turn.Duration.Milliseconds(), int64(0))
  1271. require.Less(t,
  1272. int64(*turn.FirstTokenMs),
  1273. turn.Duration.Milliseconds(),
  1274. "per-turn FirstTokenMs (%dms) should be strictly less than Duration (%dms); "+
  1275. "equality indicates the bug where first_token is mistakenly stamped on the terminal event",
  1276. *turn.FirstTokenMs, turn.Duration.Milliseconds(),
  1277. )
  1278. require.NotNil(t, result.FirstTokenMs)
  1279. require.Greater(t, *result.FirstTokenMs, 0)
  1280. }