package service import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/config" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) type stagedPassthroughFrame struct { messageType coderws.MessageType payload []byte err error } type stagedPassthroughConn struct { frames chan stagedPassthroughFrame writes chan []byte closed chan struct{} closeOnce sync.Once } func newStagedPassthroughConn() *stagedPassthroughConn { return &stagedPassthroughConn{ frames: make(chan stagedPassthroughFrame, 4), writes: make(chan []byte, 4), closed: make(chan struct{}), } } func (c *stagedPassthroughConn) Send(payload string) { c.frames <- stagedPassthroughFrame{messageType: coderws.MessageText, payload: []byte(payload)} } func (c *stagedPassthroughConn) Fail(err error) { c.frames <- stagedPassthroughFrame{err: err} } func (c *stagedPassthroughConn) WriteJSON(context.Context, any) error { return nil } func (c *stagedPassthroughConn) ReadMessage(ctx context.Context) ([]byte, error) { _, payload, err := c.ReadFrame(ctx) return payload, err } func (c *stagedPassthroughConn) Ping(context.Context) error { return nil } func (c *stagedPassthroughConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) { if ctx == nil { ctx = context.Background() } select { case <-ctx.Done(): return coderws.MessageText, nil, ctx.Err() case <-c.closed: return coderws.MessageText, nil, errOpenAIWSConnClosed case frame := <-c.frames: return frame.messageType, append([]byte(nil), frame.payload...), frame.err } } func (c *stagedPassthroughConn) WriteFrame(ctx context.Context, _ coderws.MessageType, payload []byte) error { if ctx == nil { ctx = context.Background() } select { case <-ctx.Done(): return ctx.Err() case <-c.closed: return errOpenAIWSConnClosed default: } var parsed any if err := json.Unmarshal(payload, &parsed); err != nil { return err } select { case c.writes <- append([]byte(nil), payload...): case <-ctx.Done(): return ctx.Err() case <-c.closed: return errOpenAIWSConnClosed } return nil } func (c *stagedPassthroughConn) Close() error { c.closeOnce.Do(func() { close(c.closed) }) return nil } type stagedPassthroughDialer struct { conn openAIWSClientConn } func (d *stagedPassthroughDialer) Dial(context.Context, string, http.Header, string) (openAIWSClientConn, int, http.Header, error) { return d.conn, http.StatusSwitchingProtocols, http.Header{}, nil } func newPassthroughLifecycleService(cfg *config.Config, upstream *stagedPassthroughConn) *OpenAIGatewayService { return &OpenAIGatewayService{ cfg: cfg, httpUpstream: &httpUpstreamRecorder{}, cache: &stubGatewayCache{}, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), openaiWSPassthroughDialer: &stagedPassthroughDialer{conn: upstream}, } } func passthroughLifecycleConfig() *config.Config { cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false cfg.Security.URLAllowlist.AllowInsecureHTTP = true cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 1 cfg.Gateway.OpenAIWS.Enabled = true cfg.Gateway.OpenAIWS.APIKeyEnabled = true cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 1 cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 1 cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 return cfg } func passthroughLifecycleAccount() *Account { return &Account{ ID: 901, Name: "passthrough-lifecycle", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test"}, Extra: map[string]any{ "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough, }, } } func startPassthroughLifecycleServer( t *testing.T, controlCtx context.Context, svc *OpenAIGatewayService, account *Account, ) (*httptest.Server, <-chan error) { return startPassthroughLifecycleServerWithHooks(t, controlCtx, svc, account, nil) } func startPassthroughLifecycleServerWithHooks( t *testing.T, controlCtx context.Context, svc *OpenAIGatewayService, account *Account, hooksFactory func(*gin.Context) *OpenAIWSIngressHooks, ) (*httptest.Server, <-chan error) { t.Helper() serverErr := make(chan error, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { serverErr <- err return } defer func() { _ = conn.CloseNow() }() msgType, firstMessage, err := ReadOpenAIWSClientMessage( controlCtx, conn, 3*time.Second, coderws.StatusPolicyViolation, "missing first response.create message", ) if err != nil { serverErr <- err return } if msgType != coderws.MessageText { serverErr <- errors.New("first message was not text") return } recorder := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(recorder) req := r.Clone(controlCtx) req.Header = req.Header.Clone() ginCtx.Request = req var hooks *OpenAIWSIngressHooks if hooksFactory != nil { hooks = hooksFactory(ginCtx) } serverErr <- svc.ProxyResponsesWebSocketFromClient(controlCtx, ginCtx, conn, account, "sk-test", firstMessage, hooks) })) return server, serverErr } func TestPassthroughLifecycle_LaterTurnPreOutputRateLimitRequestsReconnect(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() account := passthroughLifecycleAccount() repo := &openAIWSRateLimitSignalRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}}} svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) svc.accountRepo = repo svc.rateLimitService = &RateLimitService{accountRepo: repo} server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() firstRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) require.Equal(t, "response.create", gjson.GetBytes(firstRequest, "type").String()) upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) completed, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) cancelWrite() require.NoError(t, err) secondRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) require.Equal(t, "response.create", gjson.GetBytes(secondRequest, "type").String()) resetAt := time.Now().Add(90 * time.Minute).Unix() upstream.Send(fmt.Sprintf(`{"type":"error","error":{"code":"rate_limit_exceeded","type":"usage_limit_reached","message":"The usage limit has been reached","resets_at":%d}}`, resetAt)) _, err = readPassthroughLifecycleFrame(t, clientConn, time.Second) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusTryAgainLater, websocketCloseErr.Code) require.Equal(t, "upstream rate limit exceeded; please reconnect", websocketCloseErr.Reason) require.Len(t, repo.rateLimitCalls, 1) require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second) select { case err := <-serverErr: var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode()) case <-time.After(time.Second): t.Fatal("later-turn rate limit did not terminate passthrough") } select { case replay := <-upstream.writes: t.Fatalf("later-turn reconnect must not replay the retained first request: %s", replay) default: } } func TestPassthroughLifecycle_CyberTerminalEventsMarkBeforeAfterTurn(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string events []string wantBody string wantMessage string wantInput int wantOutput int }{ { name: "error", events: []string{ `{"type":"error","error":{"code":"cyber_policy","message":"blocked by error event"},"usage":{"input_tokens":5,"output_tokens":1}}`, `{"type":"response.failed","response":{"id":"resp_error","error":{"code":"cyber_policy","message":"blocked by paired failed event"},"usage":{"input_tokens":9,"output_tokens":2}}}`, }, wantBody: `"type":"error"`, wantMessage: "blocked by error event", wantInput: 5, wantOutput: 1, }, { name: "response_failed", events: []string{ `{"type":"response.failed","response":{"id":"resp_failed","error":{"code":"cyber_policy","message":"blocked by failed event"},"usage":{"input_tokens":9,"output_tokens":2}}}`, }, wantBody: `"type":"response.failed"`, wantMessage: "blocked by failed event", wantInput: 9, wantOutput: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() for _, event := range tt.events { upstream.Send(event) } markSeen := make(chan CyberPolicyMark, 1) afterTurnCalls := atomic.Int32{} server, serverErr := startPassthroughLifecycleServerWithHooks( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), func(c *gin.Context) *OpenAIWSIngressHooks { return &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) { afterTurnCalls.Add(1) if mark := GetOpsCyberPolicy(c); mark != nil { select { case markSeen <- *mark: default: } } }} }, ) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() for range tt.events { _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) } select { case mark := <-markSeen: require.Equal(t, "cyber_policy", mark.Code) require.Equal(t, tt.wantMessage, mark.Message) require.Contains(t, mark.Body, tt.wantBody) require.Equal(t, http.StatusOK, mark.UpstreamStatus) require.Equal(t, tt.wantInput, mark.UpstreamInTok) require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) case <-time.After(3 * time.Second): t.Fatal("cyber mark was not visible to AfterTurn") } require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("cyber passthrough test did not exit") } require.Equal(t, int32(1), afterTurnCalls.Load(), "error/response.failed pair must complete and record once") }) } } func TestPassthroughLifecycle_NonCyberFailureKeepsAccountSideEffects(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.failed","response":{"id":"resp_non_cyber","error":{"type":"authentication_error","code":"invalid_api_key","status_code":401,"message":"credential rejected"},"usage":{"input_tokens":3,"output_tokens":1}}}`) repo := &openAIStream403AccountRepo{} svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) svc.rateLimitService = NewRateLimitService(repo, nil, svc.cfg, nil, nil) account := passthroughLifecycleAccount() markSeen := make(chan *CyberPolicyMark, 1) server, serverErr := startPassthroughLifecycleServerWithHooks( t, controlCtx, svc, account, func(c *gin.Context) *OpenAIWSIngressHooks { return &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) { markSeen <- GetOpsCyberPolicy(c) }} }, ) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String()) select { case mark := <-markSeen: require.Nil(t, mark) case <-time.After(3 * time.Second): t.Fatal("non-cyber terminal event did not complete its turn") } require.Equal(t, 1, repo.setErrorCalls, "non-cyber credential failure must retain account failure side effects") require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("non-cyber passthrough test did not exit") } } func TestPassthroughLifecycle_CyberSkipsFailureAccountSideEffects(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.failed","response":{"id":"resp_cyber_auth","error":{"type":"authentication_error","code":"cyber_policy","status_code":401,"message":"request blocked"}}}`) repo := &openAIStream403AccountRepo{} svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) svc.rateLimitService = NewRateLimitService(repo, nil, svc.cfg, nil, nil) account := passthroughLifecycleAccount() server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String()) require.Zero(t, repo.setErrorCalls, "cyber_policy is request-scoped and must not cool down the account") require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("cyber side-effect test did not exit") } } func TestPassthroughLifecycle_CloseReasonTruncationPreservesUTF8(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() originalReason := strings.Repeat("a", 119) + "界" upstream.Fail(NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, originalReason, errors.New("policy rejected"))) server, serverErr := startPassthroughLifecycleServer( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), ) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) var closeErr coderws.CloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) require.True(t, utf8.ValidString(closeErr.Reason)) require.LessOrEqual(t, len(closeErr.Reason), 120) require.Equal(t, strings.Repeat("a", 119), closeErr.Reason) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("passthrough close reason test did not exit") } } func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn { t.Helper() return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`) } func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn { t.Helper() dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) cancelDial() require.NoError(t, err) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload)) cancelWrite() require.NoError(t, err) return clientConn } func readPassthroughLifecycleFrame(t *testing.T, clientConn *coderws.Conn, timeout time.Duration) ([]byte, error) { t.Helper() readCtx, cancelRead := context.WithTimeout(context.Background(), timeout) _, payload, err := clientConn.Read(readCtx) cancelRead() return payload, err } func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughConn, timeout time.Duration) []byte { t.Helper() select { case payload := <-upstream.writes: return payload case <-time.After(timeout): t.Fatal("passthrough request was not forwarded upstream") return nil } } func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{ "type":"response.create","model":"gpt-5.1","stream":false, "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} }`) defer func() { _ = clientConn.CloseNow() }() upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second) require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody)) event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("Lite 首帧测试等待 passthrough 退出超时") } } func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) { clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)} clientFrameConn.markTurnCompleted() lifecycle := newOpenAIWSPassthroughTurnLifecycle(true) lifecycle.beginTerminalWrite() admitted := make(chan bool, 1) go func() { admitted <- lifecycle.beginResponseCreate(clientFrameConn.markTurnStarted) }() select { case <-admitted: t.Fatal("next response.create was admitted before terminal commit completed") case <-time.After(50 * time.Millisecond): } lifecycle.finishTerminalWrite(true, clientFrameConn.markTurnCompleted) select { case ok := <-admitted: require.True(t, ok) case <-time.After(time.Second): t.Fatal("next response.create remained blocked after terminal commit") } require.False(t, clientFrameConn.waitingForNextTurn.Load(), "accepted next turn must win over terminal idle state") lifecycle = newOpenAIWSPassthroughTurnLifecycle(true) lifecycle.beginTerminalWrite() admitted = make(chan bool, 1) go func() { admitted <- lifecycle.beginResponseCreate(nil) }() lifecycle.finishTerminalWrite(false, func() { t.Error("failed terminal write must not commit idle state") }) require.False(t, <-admitted, "failed terminal write must keep the current turn in flight") } func TestPassthroughLifecycle_LeaseLossSendsRetryClose(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.created","response":{"id":"resp_lease","model":"gpt-5.1"}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.created", gjson.GetBytes(event, "type").String()) cancelControl(ErrOpenAIWSIngressLeaseLost) _, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) var closeErr coderws.CloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusTryAgainLater, closeErr.Code) require.Equal(t, "websocket ingress capacity lease lost; please reconnect", closeErr.Reason) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("passthrough lease-loss reader did not exit") } } func TestPassthroughLifecycle_CompletedTurnStartsInterTurnIdle(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) _, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) var closeErr coderws.CloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusNormalClosure, closeErr.Code) require.Equal(t, "websocket idle timeout", closeErr.Reason) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("passthrough idle reader did not exit") } } func TestPassthroughLifecycle_ActiveTurnInactivityUsesReadTimeout(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.output_text.delta","response_id":"resp_active","delta":"hello"}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() delta, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.output_text.delta", gjson.GetBytes(delta, "type").String()) _, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code) require.Equal(t, "upstream websocket read timeout; please reconnect", websocketCloseErr.Reason) select { case err := <-serverErr: var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode()) require.Equal(t, "upstream websocket read timeout; please reconnect", closeErr.Reason()) case <-time.After(2500 * time.Millisecond): t.Fatal("passthrough active turn remained unbounded after upstream activity stopped") } } func TestPassthroughLifecycle_PreambleAllowsPromptClientCancel(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) cfg := passthroughLifecycleConfig() cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 3 upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.created","response":{"id":"resp_cancel","model":"gpt-5.1"}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) created, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) require.NoError(t, err) require.Equal(t, "response.created", gjson.GetBytes(created, "type").String()) writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.cancel","response_id":"resp_cancel"}`)) cancelWrite() require.NoError(t, err) cancelFrame := requirePassthroughUpstreamWrite(t, upstream, 500*time.Millisecond) require.Equal(t, "response.cancel", gjson.GetBytes(cancelFrame, "type").String()) require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("passthrough cancel test did not exit") } } func TestPassthroughLifecycle_RejectsOverlappingResponseCreate(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) cfg := passthroughLifecycleConfig() cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 3 upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.created","response":{"id":"resp_overlap_first","model":"gpt-5.1"}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) created, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) require.NoError(t, err) require.Equal(t, "response.created", gjson.GetBytes(created, "type").String()) writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) cancelWrite() require.NoError(t, err) _, err = readPassthroughLifecycleFrame(t, clientConn, time.Second) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusPolicyViolation, websocketCloseErr.Code) require.Equal(t, "overlapping response.create is not supported", websocketCloseErr.Reason) select { case err := <-serverErr: var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) require.Equal(t, "overlapping response.create is not supported", closeErr.Reason()) case <-time.After(3 * time.Second): t.Fatal("overlapping response.create did not terminate passthrough") } } func TestPassthroughLifecycle_ActiveTurnActivityRefreshesReadTimeout(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"one"}`) go func() { for _, event := range []string{ `{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"two"}`, `{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"three"}`, `{"type":"response.completed","response":{"id":"resp_active_refresh","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":3}}}`, } { timer := time.NewTimer(600 * time.Millisecond) <-timer.C timer.Stop() upstream.Send(event) } }() server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() for _, wantType := range []string{ "response.output_text.delta", "response.output_text.delta", "response.output_text.delta", "response.completed", } { frame, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, wantType, gjson.GetBytes(frame, "type").String()) } require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) select { case <-serverErr: case <-time.After(3 * time.Second): t.Fatal("passthrough active-turn refresh test did not exit") } } func TestPassthroughLifecycle_TerminalSwitchesToInterTurnIdleTimeout(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) cfg := passthroughLifecycleConfig() cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 1 cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 2 upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) completed, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "resp_idle_first", gjson.GetBytes(completed, "response.id").String()) time.Sleep(1300 * time.Millisecond) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_idle_first"}`)) cancelWrite() require.NoError(t, err) require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle_second","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) completed, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "resp_idle_second", gjson.GetBytes(completed, "response.id").String()) _, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusNormalClosure, websocketCloseErr.Code) require.Equal(t, "websocket idle timeout", websocketCloseErr.Reason) select { case err := <-serverErr: var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusNormalClosure, closeErr.StatusCode()) require.Equal(t, "websocket idle timeout", closeErr.Reason()) case <-time.After(3 * time.Second): t.Fatal("passthrough terminal turn did not use inter-turn idle timeout") } } func TestPassthroughLifecycle_FirstOutputTimeoutRemainsBounded(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() select { case err := <-serverErr: var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode) require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout") case <-time.After(2500 * time.Millisecond): t.Fatal("passthrough first output was left unbounded") } } func TestPassthroughLifecycle_ResponseCreatedTimeoutClosesWithoutFailover(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.created","response":{"id":"resp_preamble","model":"gpt-5.1"}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() created, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.created", gjson.GetBytes(created, "type").String()) _, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code) require.Equal(t, "upstream produced no semantic output; please reconnect", websocketCloseErr.Reason) select { case err := <-serverErr: var failoverErr *UpstreamFailoverError require.NotErrorAs(t, err, &failoverErr) var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode()) require.Equal(t, "upstream produced no semantic output; please reconnect", closeErr.Reason()) case <-time.After(2500 * time.Millisecond): t.Fatal("response.created timeout did not close the passthrough connection") } } func TestPassthroughLifecycle_SecondTurnTimeoutIsNotFailoverSafe(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() completed, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_first"}`)) cancelWrite() require.NoError(t, err) upstream.Send(`{"type":"response.created","response":{"id":"resp_second","model":"gpt-5.1"}}`) created, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.created", gjson.GetBytes(created, "type").String()) _, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond) var websocketCloseErr coderws.CloseError require.ErrorAs(t, err, &websocketCloseErr) require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code) require.Equal(t, "upstream produced no semantic output; please reconnect", websocketCloseErr.Reason) select { case err := <-serverErr: var failoverErr *UpstreamFailoverError require.NotErrorAs(t, err, &failoverErr, "handler must not replay the initial request on another account for a later-turn timeout") var closeErr *OpenAIWSClientCloseError require.ErrorAs(t, err, &closeErr) require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode()) case <-time.After(2500 * time.Millisecond): t.Fatal("second turn first semantic output was left unbounded") } }