Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
683 lines
31 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|