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
1176 lines
38 KiB
Go
1176 lines
38 KiB
Go
package openai_ws_v2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
coderws "github.com/coder/websocket"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type passthroughTestFrame struct {
|
|
msgType coderws.MessageType
|
|
payload []byte
|
|
}
|
|
|
|
type passthroughTestFrameConn struct {
|
|
mu sync.Mutex
|
|
writes []passthroughTestFrame
|
|
readCh chan passthroughTestFrame
|
|
once sync.Once
|
|
}
|
|
|
|
type delayedReadFrameConn struct {
|
|
base FrameConn
|
|
firstDelay time.Duration
|
|
once sync.Once
|
|
}
|
|
|
|
type readStartSpyFrameConn struct {
|
|
base FrameConn
|
|
started chan struct{}
|
|
startOnce sync.Once
|
|
}
|
|
|
|
type closeSpyFrameConn struct {
|
|
closeCalls atomic.Int32
|
|
}
|
|
|
|
func newPassthroughTestFrameConn(frames []passthroughTestFrame, autoClose bool) *passthroughTestFrameConn {
|
|
c := &passthroughTestFrameConn{
|
|
readCh: make(chan passthroughTestFrame, len(frames)+1),
|
|
}
|
|
for _, frame := range frames {
|
|
copied := passthroughTestFrame{msgType: frame.msgType, payload: append([]byte(nil), frame.payload...)}
|
|
c.readCh <- copied
|
|
}
|
|
if autoClose {
|
|
close(c.readCh)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (c *passthroughTestFrameConn) 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 frame, ok := <-c.readCh:
|
|
if !ok {
|
|
return coderws.MessageText, nil, io.EOF
|
|
}
|
|
return frame.msgType, append([]byte(nil), frame.payload...), nil
|
|
}
|
|
}
|
|
|
|
func (c *passthroughTestFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.writes = append(c.writes, passthroughTestFrame{msgType: msgType, payload: append([]byte(nil), payload...)})
|
|
return nil
|
|
}
|
|
|
|
func (c *passthroughTestFrameConn) Close() error {
|
|
c.once.Do(func() {
|
|
defer func() { _ = recover() }()
|
|
close(c.readCh)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (c *passthroughTestFrameConn) Writes() []passthroughTestFrame {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]passthroughTestFrame, len(c.writes))
|
|
copy(out, c.writes)
|
|
return out
|
|
}
|
|
|
|
func (c *delayedReadFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
|
|
if c == nil || c.base == nil {
|
|
return coderws.MessageText, nil, io.EOF
|
|
}
|
|
c.once.Do(func() {
|
|
if c.firstDelay > 0 {
|
|
timer := time.NewTimer(c.firstDelay)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
})
|
|
return c.base.ReadFrame(ctx)
|
|
}
|
|
|
|
func (c *delayedReadFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
|
|
if c == nil || c.base == nil {
|
|
return io.EOF
|
|
}
|
|
return c.base.WriteFrame(ctx, msgType, payload)
|
|
}
|
|
|
|
func (c *delayedReadFrameConn) Close() error {
|
|
if c == nil || c.base == nil {
|
|
return nil
|
|
}
|
|
return c.base.Close()
|
|
}
|
|
|
|
func (c *readStartSpyFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
|
|
c.startOnce.Do(func() { close(c.started) })
|
|
return c.base.ReadFrame(ctx)
|
|
}
|
|
|
|
func (c *readStartSpyFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
|
|
return c.base.WriteFrame(ctx, msgType, payload)
|
|
}
|
|
|
|
func (c *readStartSpyFrameConn) Close() error {
|
|
return c.base.Close()
|
|
}
|
|
|
|
func (c *closeSpyFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
<-ctx.Done()
|
|
return coderws.MessageText, nil, ctx.Err()
|
|
}
|
|
|
|
func (c *closeSpyFrameConn) WriteFrame(ctx context.Context, _ coderws.MessageType, _ []byte) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *closeSpyFrameConn) Close() error {
|
|
if c != nil {
|
|
c.closeCalls.Add(1)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *closeSpyFrameConn) CloseCalls() int32 {
|
|
if c == nil {
|
|
return 0
|
|
}
|
|
return c.closeCalls.Load()
|
|
}
|
|
|
|
func TestRelay_BasicRelayAndUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_123","usage":{"input_tokens":7,"output_tokens":3,"input_tokens_details":{"cached_tokens":2}}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[{"type":"input_text","text":"hello"}]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, "gpt-5.3-codex", result.RequestModel)
|
|
require.Equal(t, "resp_123", result.RequestID)
|
|
require.Equal(t, "response.completed", result.TerminalEventType)
|
|
require.Equal(t, 7, result.Usage.InputTokens)
|
|
require.Equal(t, 3, result.Usage.OutputTokens)
|
|
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
|
require.Nil(t, result.FirstTokenMs)
|
|
require.Equal(t, int64(1), result.ClientToUpstreamFrames)
|
|
require.Equal(t, int64(1), result.UpstreamToClientFrames)
|
|
require.Equal(t, int64(0), result.DroppedDownstreamFrames)
|
|
|
|
upstreamWrites := upstreamConn.Writes()
|
|
require.Len(t, upstreamWrites, 1)
|
|
require.Equal(t, coderws.MessageText, upstreamWrites[0].msgType)
|
|
require.JSONEq(t, string(firstPayload), string(upstreamWrites[0].payload))
|
|
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 1)
|
|
require.Equal(t, coderws.MessageText, clientWrites[0].msgType)
|
|
require.JSONEq(t, `{"type":"response.completed","response":{"id":"resp_123","usage":{"input_tokens":7,"output_tokens":3,"input_tokens_details":{"cached_tokens":2}}}}`, string(clientWrites[0].payload))
|
|
}
|
|
|
|
func TestRelay_FunctionCallOutputBytesPreserved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_func","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[{"type":"function_call_output","call_id":"call_abc123","output":"{\"ok\":true}"}]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
|
|
upstreamWrites := upstreamConn.Writes()
|
|
require.Len(t, upstreamWrites, 1)
|
|
require.Equal(t, coderws.MessageText, upstreamWrites[0].msgType)
|
|
require.Equal(t, firstPayload, upstreamWrites[0].payload)
|
|
}
|
|
|
|
func TestRelay_UpstreamDisconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 上游立即关闭(EOF),客户端不发送额外帧
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, true) // 立即 close -> EOF
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
// 上游 EOF 属于 disconnect,标记为 graceful
|
|
require.Nil(t, relayExit, "上游 EOF 应被视为 graceful disconnect")
|
|
require.Equal(t, "gpt-4o", result.RequestModel)
|
|
}
|
|
|
|
func TestRelay_ClientDisconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 客户端立即关闭(EOF),上游阻塞读取直到 context 取消
|
|
clientConn := newPassthroughTestFrameConn(nil, true) // 立即 close -> EOF
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.NotNil(t, relayExit, "客户端 EOF 应返回可观测的中断状态")
|
|
require.Equal(t, "client_disconnected", relayExit.Stage)
|
|
require.Equal(t, "gpt-4o", result.RequestModel)
|
|
}
|
|
|
|
func TestRelay_ClientDisconnect_DrainCapturesLateUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, true)
|
|
upstreamBase := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_drain","usage":{"input_tokens":6,"output_tokens":4,"input_tokens_details":{"cached_tokens":1}}}}`),
|
|
},
|
|
}, true)
|
|
upstreamConn := &delayedReadFrameConn{
|
|
base: upstreamBase,
|
|
firstDelay: 80 * time.Millisecond,
|
|
}
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
UpstreamDrainTimeout: 400 * time.Millisecond,
|
|
})
|
|
require.NotNil(t, relayExit)
|
|
require.Equal(t, "client_disconnected", relayExit.Stage)
|
|
require.Equal(t, "resp_drain", result.RequestID)
|
|
require.Equal(t, "response.completed", result.TerminalEventType)
|
|
require.Equal(t, 6, result.Usage.InputTokens)
|
|
require.Equal(t, 4, result.Usage.OutputTokens)
|
|
require.Equal(t, 1, result.Usage.CacheReadInputTokens)
|
|
require.Equal(t, int64(1), result.ClientToUpstreamFrames)
|
|
require.Equal(t, int64(0), result.UpstreamToClientFrames)
|
|
require.Equal(t, int64(1), result.DroppedDownstreamFrames)
|
|
}
|
|
|
|
func TestRelay_IdleTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 客户端和上游都不发送帧,idle timeout 应触发
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// 使用快进时间来加速 idle timeout
|
|
now := time.Now()
|
|
callCount := 0
|
|
nowFn := func() time.Time {
|
|
callCount++
|
|
// 前几次调用返回正常时间(初始化阶段),之后快进
|
|
if callCount <= 5 {
|
|
return now
|
|
}
|
|
return now.Add(time.Hour) // 快进到超时
|
|
}
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
IdleTimeout: 2 * time.Second,
|
|
Now: nowFn,
|
|
})
|
|
require.NotNil(t, relayExit, "应因 idle timeout 退出")
|
|
require.Equal(t, "idle_timeout", relayExit.Stage)
|
|
require.Equal(t, "gpt-4o", result.RequestModel)
|
|
}
|
|
|
|
func TestRelay_IdleTimeoutDoesNotCloseClientOnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := &closeSpyFrameConn{}
|
|
upstreamConn := &closeSpyFrameConn{}
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
now := time.Now()
|
|
callCount := 0
|
|
nowFn := func() time.Time {
|
|
callCount++
|
|
if callCount <= 5 {
|
|
return now
|
|
}
|
|
return now.Add(time.Hour)
|
|
}
|
|
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
IdleTimeout: 2 * time.Second,
|
|
Now: nowFn,
|
|
})
|
|
require.NotNil(t, relayExit, "应因 idle timeout 退出")
|
|
require.Equal(t, "idle_timeout", relayExit.Stage)
|
|
require.Zero(t, clientConn.CloseCalls(), "错误路径不应提前关闭客户端连接,交给上层决定 close code")
|
|
require.GreaterOrEqual(t, upstreamConn.CloseCalls(), int32(1))
|
|
}
|
|
|
|
func TestRelay_NilConnections(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx := context.Background()
|
|
|
|
t.Run("nil client conn", func(t *testing.T) {
|
|
upstreamConn := newPassthroughTestFrameConn(nil, true)
|
|
_, relayExit := Relay(ctx, nil, upstreamConn, firstPayload, RelayOptions{})
|
|
require.NotNil(t, relayExit)
|
|
require.Equal(t, "relay_init", relayExit.Stage)
|
|
require.Contains(t, relayExit.Err.Error(), "nil")
|
|
})
|
|
|
|
t.Run("nil upstream conn", func(t *testing.T) {
|
|
clientConn := newPassthroughTestFrameConn(nil, true)
|
|
_, relayExit := Relay(ctx, clientConn, nil, firstPayload, RelayOptions{})
|
|
require.NotNil(t, relayExit)
|
|
require.Equal(t, "relay_init", relayExit.Stage)
|
|
require.Contains(t, relayExit.Err.Error(), "nil")
|
|
})
|
|
}
|
|
|
|
func TestRelay_MultipleUpstreamMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 上游发送多个事件(delta + completed),验证多帧中继和 usage 聚合
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","delta":"Hello"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","delta":" world"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_multi","usage":{"input_tokens":10,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[{"type":"input_text","text":"hi"}]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, "resp_multi", result.RequestID)
|
|
require.Equal(t, "response.completed", result.TerminalEventType)
|
|
require.Equal(t, 10, result.Usage.InputTokens)
|
|
require.Equal(t, 5, result.Usage.OutputTokens)
|
|
require.Equal(t, 3, result.Usage.CacheReadInputTokens)
|
|
require.NotNil(t, result.FirstTokenMs)
|
|
|
|
// 验证所有 3 个上游帧都转发给了客户端
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 3)
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_turn_1","usage":{"input_tokens":2,"output_tokens":1}}}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.failed","response":{"id":"resp_turn_2","usage":{"input_tokens":3,"output_tokens":4}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
turns := make([]RelayTurnResult, 0, 2)
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
OnTurnComplete: func(turn RelayTurnResult) {
|
|
turns = append(turns, turn)
|
|
},
|
|
})
|
|
require.Nil(t, relayExit)
|
|
require.Len(t, turns, 2)
|
|
require.Equal(t, "resp_turn_1", turns[0].RequestID)
|
|
require.Equal(t, "response.completed", turns[0].TerminalEventType)
|
|
require.Equal(t, 2, turns[0].Usage.InputTokens)
|
|
require.Equal(t, 1, turns[0].Usage.OutputTokens)
|
|
require.Equal(t, "resp_turn_2", turns[1].RequestID)
|
|
require.Equal(t, "response.failed", turns[1].TerminalEventType)
|
|
require.Equal(t, 3, turns[1].Usage.InputTokens)
|
|
require.Equal(t, 4, turns[1].Usage.OutputTokens)
|
|
require.Equal(t, 5, result.Usage.InputTokens)
|
|
require.Equal(t, 5, result.Usage.OutputTokens)
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
turns := make(chan RelayTurnResult, 2)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_, _ = Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
OnTurnComplete: func(turn RelayTurnResult) {
|
|
turns <- turn
|
|
},
|
|
})
|
|
}()
|
|
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_sol","usage":{"input_tokens":2,"output_tokens":1}}}`),
|
|
}
|
|
firstTurn := <-turns
|
|
require.Equal(t, "gpt-5.6-sol", firstTurn.RequestModel)
|
|
|
|
clientConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.create","model":"gpt-5.6-terra","input":[]}`),
|
|
}
|
|
require.Eventually(t, func() bool {
|
|
return len(upstreamConn.Writes()) == 2
|
|
}, time.Second, 10*time.Millisecond)
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_terra","usage":{"input_tokens":3,"output_tokens":1}}}`),
|
|
}
|
|
secondTurn := <-turns
|
|
require.Equal(t, "gpt-5.6-terra", secondTurn.RequestModel)
|
|
|
|
_ = clientConn.Close()
|
|
_ = upstreamConn.Close()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("relay did not stop after client close")
|
|
}
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","response_id":"resp_metric","delta":"hi"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_metric","usage":{"input_tokens":2,"output_tokens":1}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
base := time.Unix(0, 0)
|
|
var nowTick atomic.Int64
|
|
nowFn := func() time.Time {
|
|
step := nowTick.Add(1)
|
|
return base.Add(time.Duration(step) * 5 * time.Millisecond)
|
|
}
|
|
|
|
var turn RelayTurnResult
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
Now: nowFn,
|
|
OnTurnComplete: func(current RelayTurnResult) {
|
|
turn = current
|
|
},
|
|
})
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, "resp_metric", turn.RequestID)
|
|
require.Equal(t, "response.completed", turn.TerminalEventType)
|
|
require.NotNil(t, turn.FirstTokenMs)
|
|
require.GreaterOrEqual(t, *turn.FirstTokenMs, 0)
|
|
require.Greater(t, turn.Duration.Milliseconds(), int64(0))
|
|
require.NotNil(t, result.FirstTokenMs)
|
|
require.Greater(t, result.Duration.Milliseconds(), int64(0))
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_UsesResponseCreateTimeAcrossPricingBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
},
|
|
}, true)
|
|
|
|
responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
|
|
upstreamResponseAt := responseCreateAt.Add(time.Second)
|
|
var nowCalls atomic.Int64
|
|
nowFn := func() time.Time {
|
|
if nowCalls.Add(1) == 1 {
|
|
return responseCreateAt
|
|
}
|
|
return upstreamResponseAt
|
|
}
|
|
|
|
var turn RelayTurnResult
|
|
_, relayExit := Relay(
|
|
context.Background(),
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
RelayOptions{
|
|
Now: nowFn,
|
|
OnTurnComplete: func(current RelayTurnResult) { turn = current },
|
|
},
|
|
)
|
|
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, responseCreateAt, turn.StartedAt)
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_UsesExplicitFirstTurnStartedAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_initial_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
},
|
|
}, true)
|
|
|
|
responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
|
|
relayStartedAt := responseCreateAt.Add(time.Second)
|
|
var turn RelayTurnResult
|
|
_, relayExit := Relay(
|
|
context.Background(),
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
RelayOptions{
|
|
FirstTurnStartedAt: responseCreateAt,
|
|
Now: func() time.Time { return relayStartedAt },
|
|
OnTurnComplete: func(current RelayTurnResult) { turn = current },
|
|
},
|
|
)
|
|
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, responseCreateAt, turn.StartedAt)
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
firstTurnAt := time.Date(2026, time.August, 17, 9, 0, 0, 0, time.UTC)
|
|
secondTurnAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC)
|
|
secondResponseAt := secondTurnAt.Add(time.Second)
|
|
var clock atomic.Int64
|
|
clock.Store(firstTurnAt.UnixNano())
|
|
|
|
turns := make(chan RelayTurnResult, 2)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_, _ = Relay(
|
|
ctx,
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
RelayOptions{
|
|
Now: func() time.Time { return time.Unix(0, clock.Load()).UTC() },
|
|
OnTurnComplete: func(current RelayTurnResult) {
|
|
turns <- current
|
|
},
|
|
},
|
|
)
|
|
}()
|
|
|
|
require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond)
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
}
|
|
select {
|
|
case <-turns:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first turn did not complete")
|
|
}
|
|
|
|
clock.Store(secondTurnAt.UnixNano())
|
|
clientConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
}
|
|
require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond)
|
|
clock.Store(secondResponseAt.UnixNano())
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
}
|
|
|
|
var secondTurn RelayTurnResult
|
|
select {
|
|
case secondTurn = <-turns:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second turn did not complete")
|
|
}
|
|
require.Equal(t, secondTurnAt, secondTurn.StartedAt)
|
|
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("relay did not stop after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestRelay_BinaryFramePassthrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 验证 binary frame 被透传但不进行 usage 解析
|
|
binaryPayload := []byte{0x00, 0x01, 0x02, 0x03}
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageBinary,
|
|
payload: binaryPayload,
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
// binary frame 不解析 usage
|
|
require.Equal(t, 0, result.Usage.InputTokens)
|
|
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 1)
|
|
require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType)
|
|
require.Equal(t, binaryPayload, clientWrites[0].payload)
|
|
}
|
|
|
|
func TestRelay_BinaryJSONFrameSkipsObservation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageBinary,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary","usage":{"input_tokens":7,"output_tokens":3}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, 0, result.Usage.InputTokens)
|
|
require.Equal(t, "", result.RequestID)
|
|
require.Equal(t, "", result.TerminalEventType)
|
|
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 1)
|
|
require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType)
|
|
}
|
|
|
|
func TestRelay_UpstreamErrorEventPassthroughRaw(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
errorEvent := []byte(`{"type":"error","error":{"type":"invalid_request_error","message":"No tool call found"}}`)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: errorEvent,
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 1)
|
|
require.Equal(t, coderws.MessageText, clientWrites[0].msgType)
|
|
require.Equal(t, errorEvent, clientWrites[0].payload)
|
|
}
|
|
|
|
func TestRelay_PreservesFirstMessageType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
FirstMessageType: coderws.MessageBinary,
|
|
})
|
|
require.Nil(t, relayExit)
|
|
|
|
upstreamWrites := upstreamConn.Writes()
|
|
require.Len(t, upstreamWrites, 1)
|
|
require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType)
|
|
require.Equal(t, firstPayload, upstreamWrites[0].payload)
|
|
}
|
|
|
|
func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) {
|
|
baseline := SnapshotMetrics().UsageParseFailureTotal
|
|
|
|
// 上游发送无效 JSON(非 usage 格式),不应影响透传
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_bad","usage":"not_an_object"}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
require.Nil(t, relayExit)
|
|
// usage 解析失败,值为 0 但不影响透传
|
|
require.Equal(t, 0, result.Usage.InputTokens)
|
|
require.Equal(t, "response.completed", result.TerminalEventType)
|
|
|
|
// 帧仍然被转发
|
|
clientWrites := clientConn.Writes()
|
|
require.Len(t, clientWrites, 1)
|
|
require.GreaterOrEqual(t, SnapshotMetrics().UsageParseFailureTotal, baseline+1)
|
|
}
|
|
|
|
func TestRelay_WriteUpstreamFirstMessageFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 上游连接立即关闭,首包写入失败
|
|
upstreamConn := newPassthroughTestFrameConn(nil, true)
|
|
_ = upstreamConn.Close()
|
|
|
|
// 覆盖 WriteFrame 使其返回错误
|
|
errConn := &errorOnWriteFrameConn{}
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
_, relayExit := Relay(ctx, clientConn, errConn, firstPayload, RelayOptions{})
|
|
require.NotNil(t, relayExit)
|
|
require.Equal(t, "write_upstream", relayExit.Stage)
|
|
}
|
|
|
|
func TestRelay_ContextCanceled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
|
|
// 立即取消 context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{})
|
|
// context 取消导致写首包失败
|
|
require.NotNil(t, relayExit)
|
|
}
|
|
|
|
func TestRelay_DownstreamPreambleStartsClientReader(t *testing.T) {
|
|
clientBase := newPassthroughTestFrameConn(nil, false)
|
|
clientConn := &readStartSpyFrameConn{base: clientBase, started: make(chan struct{})}
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
resultCh := make(chan *RelayExit, 1)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
go func() {
|
|
_, relayExit := Relay(
|
|
ctx,
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.1"}`),
|
|
RelayOptions{
|
|
StartClientAfterFirstDownstream: true,
|
|
},
|
|
)
|
|
resultCh <- relayExit
|
|
}()
|
|
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.created","response":{"id":"resp_semantic_gate"}}`),
|
|
}
|
|
require.Eventually(t, func() bool { return len(clientBase.Writes()) == 1 }, time.Second, 10*time.Millisecond)
|
|
select {
|
|
case <-clientConn.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("response.created did not start the client reader")
|
|
}
|
|
|
|
upstreamConn.readCh <- passthroughTestFrame{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_semantic_gate","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
}
|
|
_ = upstreamConn.Close()
|
|
select {
|
|
case relayExit := <-resultCh:
|
|
require.Nil(t, relayExit)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("relay did not finish after terminal event and upstream close")
|
|
}
|
|
}
|
|
|
|
func TestRelay_TraceEvents_ContainsLifecycleStages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_trace","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
stages := make([]string, 0, 8)
|
|
var stagesMu sync.Mutex
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
OnTrace: func(event RelayTraceEvent) {
|
|
stagesMu.Lock()
|
|
stages = append(stages, event.Stage)
|
|
stagesMu.Unlock()
|
|
},
|
|
})
|
|
require.Nil(t, relayExit)
|
|
stagesMu.Lock()
|
|
capturedStages := append([]string(nil), stages...)
|
|
stagesMu.Unlock()
|
|
require.Contains(t, capturedStages, "relay_start")
|
|
require.Contains(t, capturedStages, "write_first_message_ok")
|
|
require.Contains(t, capturedStages, "first_exit")
|
|
require.Contains(t, capturedStages, "relay_complete")
|
|
}
|
|
|
|
func TestRelay_TraceEvents_IdleTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn(nil, false)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
now := time.Now()
|
|
callCount := 0
|
|
nowFn := func() time.Time {
|
|
callCount++
|
|
if callCount <= 5 {
|
|
return now
|
|
}
|
|
return now.Add(time.Hour)
|
|
}
|
|
|
|
stages := make([]string, 0, 8)
|
|
var stagesMu sync.Mutex
|
|
_, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
IdleTimeout: 2 * time.Second,
|
|
Now: nowFn,
|
|
OnTrace: func(event RelayTraceEvent) {
|
|
stagesMu.Lock()
|
|
stages = append(stages, event.Stage)
|
|
stagesMu.Unlock()
|
|
},
|
|
})
|
|
require.NotNil(t, relayExit)
|
|
require.Equal(t, "idle_timeout", relayExit.Stage)
|
|
stagesMu.Lock()
|
|
capturedStages := append([]string(nil), stages...)
|
|
stagesMu.Unlock()
|
|
require.Contains(t, capturedStages, "idle_timeout_triggered")
|
|
require.Contains(t, capturedStages, "relay_exit")
|
|
}
|
|
|
|
// errorOnWriteFrameConn 是一个写入总是失败的 FrameConn 实现,用于测试首包写入失败。
|
|
type errorOnWriteFrameConn struct{}
|
|
|
|
func (c *errorOnWriteFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
|
|
<-ctx.Done()
|
|
return coderws.MessageText, nil, ctx.Err()
|
|
}
|
|
|
|
func (c *errorOnWriteFrameConn) WriteFrame(_ context.Context, _ coderws.MessageType, _ []byte) error {
|
|
return errors.New("write failed: connection refused")
|
|
}
|
|
|
|
func (c *errorOnWriteFrameConn) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func TestRelay_NoSemanticOutputTerminalSequence_FirstTokenMsNil(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, terminalEvent := range []string{"response.completed", "response.done"} {
|
|
terminalEvent := terminalEvent
|
|
t.Run(terminalEvent, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.created","response":{"id":"resp_no_output"}}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.in_progress","response":{"id":"resp_no_output"}}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.content_part.done","response_id":"resp_no_output"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_item.done","response_id":"resp_no_output"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"` + terminalEvent + `","response":{"id":"resp_no_output","usage":{"input_tokens":2,"output_tokens":0}}}`),
|
|
},
|
|
}, true)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
var turn RelayTurnResult
|
|
result, relayExit := Relay(
|
|
ctx,
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
RelayOptions{OnTurnComplete: func(current RelayTurnResult) { turn = current }},
|
|
)
|
|
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, terminalEvent, turn.TerminalEventType)
|
|
require.Nil(t, turn.FirstTokenMs)
|
|
require.Equal(t, terminalEvent, result.TerminalEventType)
|
|
require.Nil(t, result.FirstTokenMs)
|
|
require.Equal(t, int64(5), result.UpstreamToClientFrames)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRelay_NoDeltaOutputDoneEvent_RecordsFirstTokenBeforeTerminal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
donePayload string
|
|
}{
|
|
{
|
|
name: "output text done",
|
|
donePayload: `{"type":"response.output_text.done","response_id":"resp_done","text":"hello"}`,
|
|
},
|
|
{
|
|
name: "function call arguments done",
|
|
donePayload: `{"type":"response.function_call_arguments.done","response_id":"resp_done","arguments":"{\"city\":\"Paris\"}"}`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{msgType: coderws.MessageText, payload: []byte(`{"type":"response.created","response":{"id":"resp_done"}}`)},
|
|
{msgType: coderws.MessageText, payload: []byte(tt.donePayload)},
|
|
{msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_done","usage":{"input_tokens":2,"output_tokens":1}}}`)},
|
|
}, true)
|
|
|
|
base := time.Unix(0, 0)
|
|
var nowTick atomic.Int64
|
|
nowFn := func() time.Time {
|
|
return base.Add(time.Duration(nowTick.Add(1)) * 10 * time.Millisecond)
|
|
}
|
|
var turn RelayTurnResult
|
|
result, relayExit := Relay(
|
|
context.Background(),
|
|
clientConn,
|
|
upstreamConn,
|
|
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
|
|
RelayOptions{
|
|
Now: nowFn,
|
|
OnTurnComplete: func(current RelayTurnResult) { turn = current },
|
|
},
|
|
)
|
|
|
|
require.Nil(t, relayExit)
|
|
require.NotNil(t, turn.FirstTokenMs)
|
|
require.Less(t, int64(*turn.FirstTokenMs), turn.Duration.Milliseconds())
|
|
require.NotNil(t, result.FirstTokenMs)
|
|
require.Less(t, int64(*result.FirstTokenMs), result.Duration.Milliseconds())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRelay_OnTurnComplete_RealOpenAIStream_FirstTokenMs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clientConn := newPassthroughTestFrameConn(nil, false)
|
|
upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.created","response":{"id":"resp_real"}}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","delta":"He"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","delta":"llo"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.output_text.delta","delta":" world"}`),
|
|
},
|
|
{
|
|
msgType: coderws.MessageText,
|
|
payload: []byte(`{"type":"response.completed","response":{"id":"resp_real","usage":{"input_tokens":2,"output_tokens":3}}}`),
|
|
},
|
|
}, true)
|
|
|
|
firstPayload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
base := time.Unix(0, 0)
|
|
var nowTick atomic.Int64
|
|
nowFn := func() time.Time {
|
|
step := nowTick.Add(1)
|
|
return base.Add(time.Duration(step) * 10 * time.Millisecond)
|
|
}
|
|
|
|
var turn RelayTurnResult
|
|
result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{
|
|
Now: nowFn,
|
|
OnTurnComplete: func(current RelayTurnResult) {
|
|
turn = current
|
|
},
|
|
})
|
|
require.Nil(t, relayExit)
|
|
require.Equal(t, "resp_real", turn.RequestID)
|
|
require.Equal(t, "response.completed", turn.TerminalEventType)
|
|
|
|
require.NotNil(t, turn.FirstTokenMs, "per-turn FirstTokenMs must be captured for real OpenAI streams")
|
|
require.Greater(t, turn.Duration.Milliseconds(), int64(0))
|
|
|
|
require.Less(t,
|
|
int64(*turn.FirstTokenMs),
|
|
turn.Duration.Milliseconds(),
|
|
"per-turn FirstTokenMs (%dms) should be strictly less than Duration (%dms); "+
|
|
"equality indicates the bug where first_token is mistakenly stamped on the terminal event",
|
|
*turn.FirstTokenMs, turn.Duration.Milliseconds(),
|
|
)
|
|
|
|
require.NotNil(t, result.FirstTokenMs)
|
|
require.Greater(t, *result.FirstTokenMs, 0)
|
|
}
|