Files
new-api-custom/relay/responses_websocket_test.go
T
cneicy ceddfd3d6b feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

683 lines
31 KiB
Go

package relay
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
appdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// normalizeResponsesWSTestMessage runs the read-loop envelope parse followed by
// request normalization, exactly as the session does for one response.create.
func normalizeResponsesWSTestMessage(message []byte) (responsesWSCreateRequest, string, error) {
envelope, streamID, err := parseResponsesWSEnvelope(message)
if err != nil {
return responsesWSCreateRequest{StreamID: streamID}, envelope.EventID, err
}
create, err := normalizeResponsesWSCreateEvent(message, envelope, streamID)
return create, envelope.EventID, err
}
func TestNormalizeResponsesWSMaxOutputTokens(t *testing.T) {
for _, tc := range []struct {
value string
valid bool
}{
{value: "0", valid: true},
{value: "1073741823", valid: true},
{value: "1073741824"},
{value: "18446744073686646784"},
{value: "-1"},
} {
for _, wrapped := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/wrapped=%t", tc.value, wrapped), func(t *testing.T) {
fields := `"model":"gpt-5.1","input":"hi","max_output_tokens":` + tc.value
payload := `{"type":"response.create",` + fields + `}`
if wrapped {
payload = `{"type":"response.create","response":{` + fields + `}}`
}
create, _, err := normalizeResponsesWSTestMessage([]byte(payload))
if !tc.valid {
require.Error(t, err)
assert.Equal(t, http.StatusBadRequest, newResponsesWSInvalidRequestError(err).StatusCode)
return
}
require.NoError(t, err)
require.NotNil(t, create.Request.MaxOutputTokens)
assert.Equal(t, tc.value, fmt.Sprint(*create.Request.MaxOutputTokens))
})
}
}
}
func TestSelectResponsesWSChannelHonorsPinsAndFilters(t *testing.T) {
require.NoError(t, i18n.Init())
database := setupRelayChannelDB(t)
enabled := &model.Channel{Name: "enabled", Key: "sk-test", Status: common.ChannelStatusEnabled, Type: constant.ChannelTypeOpenAI}
enabled.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
wsDisabled := &model.Channel{Name: "ws-disabled", Key: "sk-test", Status: common.ChannelStatusEnabled, Type: constant.ChannelTypeOpenAI}
disabled := &model.Channel{Name: "disabled", Key: "sk-test", Status: common.ChannelStatusManuallyDisabled, Type: constant.ChannelTypeOpenAI}
filtered := &model.Channel{Name: "filtered", Key: "sk-test", Status: common.ChannelStatusEnabled, Type: constant.ChannelTypeAdvancedCustom}
for _, channel := range []*model.Channel{enabled, disabled, filtered, wsDisabled} {
require.NoError(t, database.Create(channel).Error)
}
for _, tc := range []struct {
name string
channelID int
status int
}{
{name: "token pin overrides origin pin", channelID: enabled.Id},
{name: "disabled pin rejects", channelID: disabled.Id, status: http.StatusForbidden},
{name: "pin cannot bypass websocket switch", channelID: wsDisabled.Id, status: http.StatusBadRequest},
{name: "pin cannot bypass path filter", channelID: filtered.Id, status: http.StatusBadRequest},
{name: "missing pin rejects", channelID: 99999, status: http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
constraints := service.GetChannelConstraints(c)
constraints.AddPin(appdto.ChannelPin{ChannelId: disabled.Id, Source: appdto.PinSourceOriginTask, Rank: appdto.PinRankOriginTask, RetryMode: appdto.PinRetrySameChannel})
constraints.AddPin(appdto.ChannelPin{ChannelId: tc.channelID, Source: appdto.PinSourceToken, Rank: appdto.PinRankToken, RetryMode: appdto.PinRetrySingleAttempt})
constraints.AddFilter(appdto.ChannelFilter{Kind: appdto.FilterRequestPath, RequestPath: c.Request.URL.Path})
channel, apiErr := selectResponsesWSChannel(c, "gpt-5.1", &service.RetryParam{Ctx: c, ModelName: "gpt-5.1", TokenGroup: "default"})
if tc.status != 0 {
require.NotNil(t, apiErr)
assert.Equal(t, tc.status, apiErr.StatusCode)
assert.Nil(t, channel)
assert.False(t, service.ShouldRetryRelayError(c, apiErr, 2))
return
}
require.Nil(t, apiErr)
require.NotNil(t, channel)
assert.Equal(t, enabled.Id, channel.Id)
assert.Equal(t, enabled.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
assert.False(t, service.ShouldRetryRelayError(c, types.NewErrorWithStatusCode(errors.New("upstream failed"), types.ErrorCodeDoRequestFailed, 503), 2))
})
}
}
// The WebSocket relay must admit the same model names as the HTTP distributor
// when a token restricts models (reasoning suffixes and @modifiers included).
func TestCheckResponsesWSModelAccessMatchesHTTPTokenLimits(t *testing.T) {
for _, tc := range []struct {
model string
status int
}{
{model: "gpt-5.1"},
{model: "gpt-5.1-high"},
{model: "gpt-5.1@thinking:on"},
{model: "gpt-4o", status: http.StatusForbidden},
} {
t.Run(tc.model, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
common.SetContextKey(c, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(c, constant.ContextKeyTokenModelLimit, map[string]bool{"gpt-5.1": true})
apiErr := checkResponsesWSModelAccess(c, tc.model)
if tc.status == 0 {
assert.Nil(t, apiErr)
return
}
require.NotNil(t, apiErr)
assert.Equal(t, tc.status, apiErr.StatusCode)
assert.False(t, service.ShouldRetryRelayError(c, apiErr, 2))
})
}
}
func TestSelectResponsesWSChannelAcceptsNativeResponsesChannelTypes(t *testing.T) {
require.NoError(t, i18n.Init())
database := setupRelayChannelDB(t)
nativeRoute := &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v1/responses"}}}
noneRoute := &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v1/responses", Converter: "none"}}}
convertedRoute := &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v1/chat/completions", Converter: "openai_responses_to_openai_chat_completions"}}}
chatRoute := &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/chat/completions", UpstreamPath: "/v1/chat/completions"}}}
for _, tc := range []struct {
name string
channelType int
advanced *dto.AdvancedCustomConfig
rejectedBy appdto.ChannelFilterKind
}{
{name: "new api", channelType: constant.ChannelTypeNewAPI},
{name: "sub2api", channelType: constant.ChannelTypeSub2API},
{name: "advanced custom native responses route", channelType: constant.ChannelTypeAdvancedCustom, advanced: nativeRoute},
{name: "advanced custom explicit none converter", channelType: constant.ChannelTypeAdvancedCustom, advanced: noneRoute},
{name: "advanced custom converter route", channelType: constant.ChannelTypeAdvancedCustom, advanced: convertedRoute, rejectedBy: appdto.FilterResponsesWebSocket},
{name: "advanced custom without responses route", channelType: constant.ChannelTypeAdvancedCustom, advanced: chatRoute, rejectedBy: appdto.FilterRequestPath},
{name: "anthropic", channelType: constant.ChannelTypeAnthropic, rejectedBy: appdto.FilterResponsesWebSocket},
} {
t.Run(tc.name, func(t *testing.T) {
channel := &model.Channel{Name: tc.name, Key: "sk-test", Status: common.ChannelStatusEnabled, Type: tc.channelType}
channel.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: tc.advanced})
require.NoError(t, database.Create(channel).Error)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
constraints := service.GetChannelConstraints(c)
constraints.AddPin(appdto.ChannelPin{ChannelId: channel.Id, Source: appdto.PinSourceToken, Rank: appdto.PinRankToken, RetryMode: appdto.PinRetrySingleAttempt})
constraints.AddFilter(appdto.ChannelFilter{Kind: appdto.FilterRequestPath, RequestPath: c.Request.URL.Path})
selected, apiErr := selectResponsesWSChannel(c, "ws-model", &service.RetryParam{Ctx: c, ModelName: "ws-model", TokenGroup: "default"})
if tc.rejectedBy != "" {
require.NotNil(t, apiErr)
assert.Nil(t, selected)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, types.ErrorCode(tc.rejectedBy), apiErr.GetErrorCode())
return
}
require.Nil(t, apiErr)
require.NotNil(t, selected)
assert.Equal(t, channel.Id, selected.Id)
})
}
}
func TestRestoreConnectionContextRejectsChangedAdvancedCustomRoute(t *testing.T) {
database := setupRelayChannelDB(t)
baseURL := "http://upstream.example"
channel := &model.Channel{Name: "advanced", Key: "sk-test", Status: common.ChannelStatusEnabled, Type: constant.ChannelTypeAdvancedCustom, BaseURL: &baseURL}
channel.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v1/responses"}}}})
require.NoError(t, database.Create(channel).Error)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
service.GetChannelConstraints(c).AddPin(appdto.ChannelPin{ChannelId: channel.Id, Source: appdto.PinSourceToken, Rank: appdto.PinRankToken, RetryMode: appdto.PinRetrySingleAttempt})
route, ok := channel.GetOtherSettings().AdvancedCustom.MatchPathForModel("/v1/responses", "ws-model")
require.True(t, ok)
session := &responsesWSSession{lockedChannelID: channel.Id, lockedModel: "ws-model", lockedKey: "sk-test", lockedRoute: route,
lockedContext: map[constant.ContextKey]any{constant.ContextKeyChannelBaseUrl: channel.GetBaseURL(), constant.ContextKeyChannelHeaderOverride: channel.GetHeaderOverride()}}
require.Nil(t, session.restoreConnectionContext(c, "ws-model"))
// Request-level edits (model list, explicit none converter) keep the connection.
channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v1/responses", Converter: "none", Models: []string{"ws-model", "other-model"}}}}})
require.NoError(t, database.Model(channel).Update("settings", channel.OtherSettings).Error)
require.Nil(t, session.restoreConnectionContext(c, "ws-model"))
channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/responses", UpstreamPath: "/v2/responses"}}}})
require.NoError(t, database.Model(channel).Update("settings", channel.OtherSettings).Error)
apiErr := session.restoreConnectionContext(c, "ws-model")
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusForbidden, apiErr.StatusCode)
assert.ErrorContains(t, apiErr, "upstream route changed")
}
func TestResponsesWSChannelRoutingRequiresExplicitOptIn(t *testing.T) {
require.NoError(t, i18n.Init())
database := setupRelayChannelDB(t)
require.NoError(t, database.AutoMigrate(&model.Ability{}))
legacy := &model.Channel{Name: "legacy-http", Key: "sk-test", Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Group: "default", Models: "ws-model", Priority: common.GetPointer(int64(10))}
enabled := &model.Channel{Name: "websocket", Key: "sk-test", Type: constant.ChannelTypeCodex, Status: common.ChannelStatusEnabled, Group: "default", Models: "ws-model", Priority: common.GetPointer(int64(0))}
unsupported := &model.Channel{Name: "unsupported", Key: "sk-test", Type: constant.ChannelTypeAnthropic, Status: common.ChannelStatusEnabled, Group: "default", Models: "ws-model", Priority: common.GetPointer(int64(5))}
enabled.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
unsupported.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
for _, channel := range []*model.Channel{legacy, enabled, unsupported} {
require.NoError(t, database.Create(channel).Error)
require.NoError(t, database.Create(&model.Ability{ChannelId: channel.Id, Model: "ws-model", Group: "default", Enabled: true, Priority: channel.Priority}).Error)
}
previousCache := common.MemoryCacheEnabled
t.Cleanup(func() {
defer func() { common.MemoryCacheEnabled = previousCache }()
ids := []int{legacy.Id, enabled.Id, unsupported.Id}
require.NoError(t, database.Where("channel_id IN ?", ids).Delete(&model.Ability{}).Error)
require.NoError(t, database.Where("id IN ?", ids).Delete(&model.Channel{}).Error)
model.InitChannelCache()
})
common.MemoryCacheEnabled = true
model.InitChannelCache()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
params := &service.RetryParam{Ctx: c, ModelName: "ws-model", TokenGroup: "default"}
channel, apiErr := selectResponsesWSChannel(c, "ws-model", params)
require.Nil(t, apiErr)
require.NotNil(t, channel)
assert.Equal(t, enabled.Id, channel.Id)
httpChannel, err := model.GetRandomSatisfiedChannel("default", "ws-model", 0, nil)
require.NoError(t, err)
require.NotNil(t, httpChannel)
assert.Equal(t, legacy.Id, httpChannel.Id)
// Disabling the saved setting takes effect for the next create on an existing session.
enabled.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: false})
require.NoError(t, database.Model(enabled).Update("setting", enabled.Setting).Error)
model.InitChannelCache()
session := &responsesWSSession{lockedChannelID: enabled.Id, lockedModel: "ws-model"}
apiErr = session.restoreConnectionContext(c, "ws-model")
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusForbidden, apiErr.StatusCode)
channel, apiErr = selectResponsesWSChannel(c, "ws-model", params)
require.NotNil(t, apiErr)
assert.Nil(t, channel)
}
// Session affinity follows the HTTP distributor: a strict binding to an
// unusable channel fails the request, a prefer binding falls back, and an
// unusable binding is dropped from the cache either way.
func TestSelectResponsesWSChannelHonorsStrictSessionBinding(t *testing.T) {
require.NoError(t, i18n.Init())
database := setupRelayChannelDB(t)
require.NoError(t, database.AutoMigrate(&model.Ability{}))
bound := &model.Channel{Name: "bound", Key: "sk-test", Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusAutoDisabled, Group: "default", Models: "ws-model", Priority: common.GetPointer(int64(10))}
fallback := &model.Channel{Name: "fallback", Key: "sk-test", Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Group: "default", Models: "ws-model", Priority: common.GetPointer(int64(0))}
for _, channel := range []*model.Channel{bound, fallback} {
channel.SetSetting(dto.ChannelSettings{ResponsesWebSocketEnabled: true})
require.NoError(t, database.Create(channel).Error)
require.NoError(t, database.Create(&model.Ability{ChannelId: channel.Id, Model: "ws-model", Group: "default", Enabled: channel.Status == common.ChannelStatusEnabled, Priority: channel.Priority}).Error)
}
previousCache := common.MemoryCacheEnabled
t.Cleanup(func() {
defer func() { common.MemoryCacheEnabled = previousCache }()
ids := []int{bound.Id, fallback.Id}
require.NoError(t, database.Where("channel_id IN ?", ids).Delete(&model.Ability{}).Error)
require.NoError(t, database.Where("id IN ?", ids).Delete(&model.Channel{}).Error)
model.InitChannelCache()
})
common.MemoryCacheEnabled = true
model.InitChannelCache()
affinity := operation_setting.GetChannelAffinitySetting()
previousAffinity := *affinity
t.Cleanup(func() { *affinity = previousAffinity })
snapshot, err := model.BuildRequestPolicy(map[string]string{
"channel_affinity_setting.enabled": "true",
"channel_affinity_setting.session_mode": "strict",
"channel_affinity_setting.rules": `[{"name":"session","model_regex":[".*"],"key_sources":[{"type":"request_header","key":"X-Session"}],"session_mode":"inherit"}]`,
})
require.NoError(t, err)
*affinity = snapshot.Affinity
newSessionContext := func() *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("X-Session", t.Name())
return c
}
seed := newSessionContext()
_, found := service.GetPreferredChannelByAffinity(seed, "ws-model", "default")
require.False(t, found)
seed.Set("channel_id", bound.Id)
service.RecordChannelAffinity(seed, bound.Id)
t.Cleanup(func() { service.ClearCurrentChannelAffinityCache(seed) })
strict := newSessionContext()
channel, apiErr := selectResponsesWSChannel(strict, "ws-model", &service.RetryParam{Ctx: strict, ModelName: "ws-model", TokenGroup: "default", Retry: common.GetPointer(0)})
require.NotNil(t, apiErr)
assert.Nil(t, channel)
assert.Equal(t, http.StatusServiceUnavailable, apiErr.StatusCode)
assert.False(t, service.ShouldRetryRelayError(strict, apiErr, 2))
_, found = service.GetPreferredChannelByAffinity(seed, "ws-model", "default")
assert.False(t, found, "an unusable binding is cleared unless keep_on_channel_disabled is set")
affinity.SessionMode = "prefer"
service.RecordChannelAffinity(seed, bound.Id)
prefer := newSessionContext()
channel, apiErr = selectResponsesWSChannel(prefer, "ws-model", &service.RetryParam{Ctx: prefer, ModelName: "ws-model", TokenGroup: "default", Retry: common.GetPointer(0)})
require.Nil(t, apiErr)
require.NotNil(t, channel)
assert.Equal(t, fallback.Id, channel.Id)
}
func TestNormalizeResponsesWSCreateEventWrapper(t *testing.T) {
message := []byte(`{
"type": "response.create",
"event_id": "evt_1",
"generate": false,
"response": {
"model": "gpt-5.3-codex-spark",
"input": "hi",
"store": false,
"stream": true,
"stream_options": {"include_usage": true}
}
}`)
create, eventID, err := normalizeResponsesWSTestMessage(message)
if err != nil {
t.Fatalf("normalizeResponsesWSTestMessage() error = %v", err)
}
req := create.Request
if eventID != "evt_1" {
t.Fatalf("eventID = %q, want evt_1", eventID)
}
if req.Model != "gpt-5.3-codex-spark" {
t.Fatalf("model = %q", req.Model)
}
if strings.TrimSpace(string(create.Generate)) != "false" {
t.Fatalf("generate = %s, want false", create.Generate)
}
if req.Stream != nil {
t.Fatalf("stream = %v, want nil", req.Stream)
}
if req.StreamOptions != nil {
t.Fatalf("stream_options = %#v, want nil", req.StreamOptions)
}
if strings.TrimSpace(string(req.Store)) != "false" {
t.Fatalf("store = %s, want false", req.Store)
}
}
func TestNormalizeResponsesWSCreateEventFlat(t *testing.T) {
message := []byte(`{
"type": "response.create",
"event_id": "evt_2",
"model": "gpt-5.3-codex-spark",
"input": "hi",
"generate": false,
"stream": true,
"background": true,
"stream_options": {"include_usage": true}
}`)
create, eventID, err := normalizeResponsesWSTestMessage(message)
if err != nil {
t.Fatalf("normalizeResponsesWSTestMessage() error = %v", err)
}
req := create.Request
if eventID != "evt_2" {
t.Fatalf("eventID = %q, want evt_2", eventID)
}
if req.Model != "gpt-5.3-codex-spark" {
t.Fatalf("model = %q", req.Model)
}
if strings.TrimSpace(string(create.Generate)) != "false" {
t.Fatalf("generate = %s, want false", create.Generate)
}
if req.Stream != nil {
t.Fatalf("stream = %v, want nil", req.Stream)
}
if req.StreamOptions != nil {
t.Fatalf("stream_options = %#v, want nil", req.StreamOptions)
}
}
func TestBuildResponsesWSCreateEventIsFlat(t *testing.T) {
payload := []byte(`{
"model": "gpt-5.3-codex-spark",
"input": "hi",
"store": false,
"event_id": "evt_upstream",
"stream": true,
"background": true,
"stream_options": {"include_usage": true}
}`)
got, err := buildResponsesWSCreateEvent(payload, common.RawMessage(`false`), "")
if err != nil {
t.Fatalf("buildResponsesWSCreateEvent() error = %v", err)
}
var data map[string]any
if err := common.Unmarshal(got, &data); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if data["type"] != responsesWSEventTypeResponseCreate {
t.Fatalf("type = %#v", data["type"])
}
if data["model"] != "gpt-5.3-codex-spark" || data["input"] != "hi" || data["store"] != false {
t.Fatalf("unexpected flat event fields: %s", got)
}
if data["generate"] != false {
t.Fatalf("generate = %#v, want false", data["generate"])
}
for _, key := range []string{"response", "event_id", "stream", "background", "stream_options"} {
if _, ok := data[key]; ok {
t.Fatalf("field %q should not be present in upstream event: %s", key, got)
}
}
}
func TestHTTPResponsesRequestOmitsWebSocketMetadata(t *testing.T) {
var req dto.OpenAIResponsesRequest
require.NoError(t, common.Unmarshal([]byte(`{"model":"gpt-5.3-codex-spark","input":"hi","generate":false,"stream_id":"planner"}`), &req))
got, err := common.Marshal(req)
require.NoError(t, err)
var data map[string]any
require.NoError(t, common.Unmarshal(got, &data))
assert.NotContains(t, data, "stream_id")
assert.NotContains(t, data, "generate")
}
func TestBuildResponsesWSErrorPayloadIncludesStatus(t *testing.T) {
payload, err := buildResponsesWSErrorPayload("evt_err", "", types.NewErrorWithStatusCode(
errors.New("model is required"),
types.ErrorCodeInvalidRequest,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
))
if err != nil {
t.Fatalf("buildResponsesWSErrorPayload() error = %v", err)
}
var data struct {
Type string `json:"type"`
Status int `json:"status"`
EventID string `json:"event_id"`
Error *types.OpenAIError `json:"error"`
}
if err := common.Unmarshal(payload, &data); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if data.Type != "error" || data.Status != http.StatusBadRequest || data.EventID != "evt_err" {
t.Fatalf("unexpected error event: %s", payload)
}
if data.Error == nil || data.Error.Code != string(types.ErrorCodeInvalidRequest) {
t.Fatalf("unexpected error body: %#v", data.Error)
}
}
func TestResponsesWSInvalidRequestErrorUsesBadRequestStatus(t *testing.T) {
payload, err := buildResponsesWSErrorPayload("", "", newResponsesWSInvalidRequestError(errors.New("bad event")))
if err != nil {
t.Fatalf("buildResponsesWSErrorPayload() error = %v", err)
}
var data struct {
Status int `json:"status"`
}
if err := common.Unmarshal(payload, &data); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if data.Status != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", data.Status, http.StatusBadRequest)
}
}
func newTestResponsesWSTarget(t *testing.T) (*websocket.Conn, func()) {
t.Helper()
target, _, cleanup := newTestWebSocketPair(t)
return target, cleanup
}
func newTestWebSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) {
t.Helper()
upgrader := websocket.Upgrader{}
serverConnCh := make(chan *websocket.Conn, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade websocket: %v", err)
return
}
serverConnCh <- conn
}))
targetURL := "ws" + strings.TrimPrefix(server.URL, "http")
target, _, err := websocket.DefaultDialer.Dial(targetURL, nil)
if err != nil {
server.Close()
t.Fatalf("dial websocket: %v", err)
}
serverConn := <-serverConnCh
cleanup := func() {
_ = target.Close()
_ = serverConn.Close()
server.Close()
}
return target, serverConn, cleanup
}
func TestResponsesWSMessageSizeLimit(t *testing.T) {
previous := constant.MaxRequestBodyMB
constant.MaxRequestBodyMB = 1
t.Cleanup(func() { constant.MaxRequestBodyMB = previous })
client, server, cleanup := newTestWebSocketPair(t)
defer cleanup()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
var admitted atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
ResponsesWebSocketHelper(c, server, func(*http.Request, string, func(*gin.Context) *types.NewAPIError) *types.NewAPIError {
admitted.Store(true)
return nil
})
}()
_ = client.WriteMessage(websocket.TextMessage, []byte(strings.Repeat("x", (1<<20)+1)))
require.NoError(t, client.SetReadDeadline(time.Now().Add(5*time.Second)))
_, _, err := client.ReadMessage()
assert.True(t, websocket.IsCloseError(err, websocket.CloseMessageTooBig), "oversized message must be rejected before admission: %v", err)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("websocket request did not exit after oversized message")
}
assert.False(t, admitted.Load())
}
func TestResponsesWSShutdownInterruptsBusyWriter(t *testing.T) {
client, server, cleanupClient := newTestWebSocketPair(t)
defer cleanupClient()
target, peer, cleanupTarget := newTestWebSocketPair(t)
defer cleanupTarget()
ctx, cancel := context.WithCancel(context.Background())
s := &responsesWSSession{ctx: ctx, cancel: cancel, client: server, target: target}
// A blocked network writer owns this lock. Closing the connection must
// remain possible so that writer can be interrupted.
s.targetWriteMu.Lock()
defer s.targetWriteMu.Unlock()
done := make(chan struct{})
go func() { s.shutdown(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("shutdown waited for the network writer")
}
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
_, _, err := peer.ReadMessage()
assert.Error(t, err)
require.NoError(t, client.SetReadDeadline(time.Now().Add(time.Second)))
_, _, err = client.ReadMessage()
assert.Error(t, err)
assert.Nil(t, s.getTarget())
}
func TestResponsesWSPassthroughPreservesRawPricingParameters(t *testing.T) {
create, _, err := normalizeResponsesWSTestMessage([]byte(`{"type":"response.create","generate":false,"response":{"model":"gpt-5.1","input":"hi","vendor":{"tier":"premium"},"stream":true}}`))
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(create.Body)))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, constant.ContextKeyOriginalModel, create.Request.Model)
common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeOpenAI)
common.SetContextKey(c, constant.ContextKeyChannelSetting, dto.ChannelSettings{PassThroughBodyEnabled: true})
info := relaycommon.GenRelayInfoResponses(c, &create.Request)
payload, apiErr := buildResponsesWSCreatePayload(c, info, create.Request, create.Generate, create.StreamID)
require.Nil(t, apiErr)
assert.JSONEq(t, `{"type":"response.create","generate":false,"model":"gpt-5.1","input":"hi","vendor":{"tier":"premium"}}`, string(payload))
storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
require.NoError(t, storage.Close())
}
func TestResponsesWSStreamIdentity(t *testing.T) {
for _, tc := range []struct {
name, fields, want string
invalid bool
}{
{name: "default", fields: `"model":"gpt-4o"`},
{name: "named", fields: `"model":"gpt-4o","stream_id":"planner.A-1_2"`, want: "planner.A-1_2"},
{name: "wrapped", fields: `"response":{"model":"gpt-4o","stream_id":"wrapped"}`, want: "wrapped"},
{name: "top-level-wins", fields: `"stream_id":"outer","response":{"model":"gpt-4o","stream_id":"inner"}`, want: "outer"},
{name: "maximum", fields: `"model":"gpt-4o","stream_id":"` + strings.Repeat("a", 256) + `"`, want: strings.Repeat("a", 256)},
{name: "too-long", fields: `"stream_id":"` + strings.Repeat("a", 257) + `"`, invalid: true},
{name: "empty", fields: `"stream_id":""`, invalid: true},
{name: "null", fields: `"stream_id":null`, invalid: true},
{name: "number", fields: `"stream_id":123`, invalid: true},
{name: "unicode", fields: `"stream_id":"计划"`, invalid: true},
{name: "spaces", fields: `"stream_id":"a b"`, invalid: true},
{name: "invalid-top-level", fields: `"stream_id":"","response":{"stream_id":"inner"}`, invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
create, eventID, err := normalizeResponsesWSTestMessage([]byte(`{"type":"response.create","event_id":"test",` + tc.fields + `}`))
assert.Equal(t, "test", eventID)
if tc.invalid {
require.ErrorContains(t, err, "stream_id")
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, create.StreamID)
assert.NotContains(t, string(create.Body), "stream_id")
for _, passthrough := range []bool{false, true} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(create.Body)))
common.SetContextKey(c, constant.ContextKeyOriginalModel, create.Request.Model)
common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeOpenAI)
common.SetContextKey(c, constant.ContextKeyChannelSetting, dto.ChannelSettings{PassThroughBodyEnabled: passthrough})
info := relaycommon.GenRelayInfoResponses(c, &create.Request)
payload, apiErr := buildResponsesWSCreatePayload(c, info, create.Request, create.Generate, create.StreamID)
require.Nil(t, apiErr)
var event map[string]any
require.NoError(t, common.Unmarshal(payload, &event))
if tc.want == "" {
assert.NotContains(t, event, "stream_id")
} else {
assert.Equal(t, tc.want, event["stream_id"])
}
if storage, err := common.GetBodyStorage(c); err == nil {
require.NoError(t, storage.Close())
}
}
})
}
}
func TestResponsesWSErrorAttribution(t *testing.T) {
for _, tc := range []struct {
name, payload, control string
terminal, ambiguous, controlError bool
}{
{name: "active-server-failure", payload: `{"status":500,"error":{"type":"server_error"}}`, terminal: true},
{name: "other-stream", payload: `{"stream_id":"other","status":500}`},
{name: "previous-response", payload: `{"response_id":"previous","status":500}`},
{name: "cancel-event-id", payload: `{"event_id":"cancel","status":500}`, control: `{"event_id":"cancel"}`, controlError: true},
{name: "cancel-target", payload: `{"response_id":"missing","status":400}`, control: `{"response_id":"missing"}`, controlError: true},
{name: "cancel-code", payload: `{"error":{"type":"invalid_request_error","code":"response_not_found"}}`, control: `{"response_id":"missing"}`, controlError: true},
{name: "ambiguous-after-cancel", payload: `{"status":500}`, control: `{"response_id":"active"}`, terminal: true, ambiguous: true},
{name: "explicit-generation-failure", payload: `{"response_id":"active","status":500}`, control: `{"response_id":"active"}`, terminal: true},
} {
t.Run(tc.name, func(t *testing.T) {
var event responsesWSErrorEvent
require.NoError(t, common.Unmarshal([]byte(tc.payload), &event))
terminal, ambiguous, controlError := responsesWSErrorEndsRequest(event, "planner", "active", []byte(tc.control))
assert.Equal(t, tc.terminal, terminal)
assert.Equal(t, tc.ambiguous, ambiguous)
assert.Equal(t, tc.controlError, controlError)
})
}
}