| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904 |
- 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")
- }
- }
|