Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
592 lines
24 KiB
Go
592 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
|
|
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) 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...), nil
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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
|
|
serverErr <- svc.ProxyResponsesWebSocketFromClient(controlCtx, ginCtx, conn, account, "sk-test", firstMessage, nil)
|
|
}))
|
|
return server, serverErr
|
|
}
|
|
|
|
func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *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(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
|
|
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 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")
|
|
}
|
|
}
|