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.
940 lines
36 KiB
Go
940 lines
36 KiB
Go
package relay
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
appconstant "github.com/QuantumNous/new-api/constant"
|
|
appdto "github.com/QuantumNous/new-api/dto"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
appmodel "github.com/QuantumNous/new-api/model"
|
|
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
|
"github.com/QuantumNous/new-api/pkg/wsmanager"
|
|
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relay/helper"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const responsesWSEventTypeResponseCreate = "response.create"
|
|
const responsesWSWriteTimeout = 30 * time.Second
|
|
|
|
// ResponsesWSRequestRunner executes the existing authentication and rate-limit
|
|
// middleware around one complete request, without a second HTTP connection.
|
|
type ResponsesWSRequestRunner func(*http.Request, string, func(*gin.Context) *types.NewAPIError) *types.NewAPIError
|
|
|
|
type responsesWSCreateEvent struct {
|
|
Type string `json:"type"`
|
|
EventID string `json:"event_id,omitempty"`
|
|
StreamID common.RawMessage `json:"stream_id,omitempty"`
|
|
Generate common.RawMessage `json:"generate,omitempty"`
|
|
Request common.RawMessage `json:"response,omitempty"`
|
|
}
|
|
|
|
type responsesWSCreateRequest struct {
|
|
Request dto.OpenAIResponsesRequest
|
|
Body []byte
|
|
Generate common.RawMessage
|
|
StreamID string
|
|
}
|
|
|
|
type responsesWSErrorEvent struct {
|
|
Type string `json:"type"`
|
|
Status int `json:"status"`
|
|
EventID string `json:"event_id,omitempty"`
|
|
StreamID string `json:"stream_id,omitempty"`
|
|
ResponseID string `json:"response_id,omitempty"`
|
|
Error *types.OpenAIError `json:"error"`
|
|
}
|
|
|
|
type responsesWSMessage struct {
|
|
kind int
|
|
body []byte
|
|
err error
|
|
}
|
|
|
|
// responsesWSControl is a client control event (response.cancel) whose
|
|
// envelope the read loop already parsed.
|
|
type responsesWSControl struct {
|
|
body []byte
|
|
eventID, streamID string
|
|
}
|
|
|
|
// Only the request worker reads or changes billing state. Socket readers pass
|
|
// bounded messages to it; cancellation never performs an independent refund.
|
|
type responsesWSCallState struct {
|
|
inbox chan responsesWSMessage
|
|
controls chan responsesWSControl
|
|
done chan struct{}
|
|
terminal *responsesWSMessage
|
|
closeAfter bool
|
|
}
|
|
|
|
type responsesWSSession struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
client *websocket.Conn
|
|
runner ResponsesWSRequestRunner
|
|
request *http.Request
|
|
requestID string
|
|
nextEventIndex int
|
|
workers sync.WaitGroup
|
|
|
|
clientWriteMu sync.Mutex
|
|
targetWriteMu sync.Mutex
|
|
connectionMu sync.Mutex
|
|
target *websocket.Conn
|
|
unregister func()
|
|
stateMu sync.Mutex
|
|
current *responsesWSCallState
|
|
|
|
// These fields belong to the serial request worker and describe the actual
|
|
// established connection. Per-request token/user data is never stored here.
|
|
lastResponseID string
|
|
lockedModel string
|
|
lockedChannelID int
|
|
lockedGroup string
|
|
lockedKey string
|
|
lockedKeyIndex int
|
|
lockedContext map[appconstant.ContextKey]any
|
|
lockedRoute dto.AdvancedCustomRoute
|
|
}
|
|
|
|
func ResponsesWebSocketHelper(c *gin.Context, client *websocket.Conn, runner ResponsesWSRequestRunner) *types.NewAPIError {
|
|
ctx, cancel := context.WithCancel(c.Request.Context())
|
|
s := &responsesWSSession{ctx: ctx, cancel: cancel, client: client, runner: runner,
|
|
request: c.Request.Clone(ctx), requestID: c.GetString(common.RequestIdKey)}
|
|
if s.requestID == "" {
|
|
s.requestID = common.NewRequestId()
|
|
}
|
|
maxMB := appconstant.MaxRequestBodyMB
|
|
if maxMB <= 0 {
|
|
maxMB = 128
|
|
}
|
|
client.SetReadLimit(int64(maxMB) << 20)
|
|
defer func() {
|
|
s.shutdown()
|
|
s.workers.Wait()
|
|
}()
|
|
|
|
for {
|
|
_, message, err := client.ReadMessage()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
envelope, streamID, err := parseResponsesWSEnvelope(message)
|
|
eventType := envelope.Type
|
|
if err != nil {
|
|
s.sendError(envelope.EventID, streamID, newResponsesWSInvalidRequestError(err))
|
|
continue
|
|
}
|
|
if eventType != responsesWSEventTypeResponseCreate {
|
|
// Controls are owned by the active request too. In particular a cancel
|
|
// arriving during authentication must not precede its upstream create.
|
|
state := s.getCurrent()
|
|
if eventType != "response.cancel" || state == nil {
|
|
s.sendError(envelope.EventID, streamID, newResponsesWSInvalidRequestError(fmt.Errorf("unsupported websocket event %q", eventType)))
|
|
continue
|
|
}
|
|
select {
|
|
case state.controls <- responsesWSControl{body: message, eventID: envelope.EventID, streamID: streamID}:
|
|
case <-state.done:
|
|
case <-s.ctx.Done():
|
|
return nil
|
|
default:
|
|
s.sendError(envelope.EventID, streamID, newResponsesWSInvalidRequestError(errors.New("a response control event is already pending")))
|
|
}
|
|
continue
|
|
}
|
|
state := &responsesWSCallState{inbox: make(chan responsesWSMessage), controls: make(chan responsesWSControl, 1), done: make(chan struct{})}
|
|
if !s.tryReserveCurrent(state) {
|
|
s.sendError(envelope.EventID, streamID, types.NewErrorWithStatusCode(errors.New("another response.create is already in progress on this websocket connection"), types.ErrorCodeInvalidRequest, http.StatusConflict, types.ErrOptionWithSkipRetry()))
|
|
continue
|
|
}
|
|
requestID := fmt.Sprintf("%s-ws-%d", s.requestID, s.nextEventIndex)
|
|
s.nextEventIndex++
|
|
s.workers.Go(func() { s.runRequest(state, message, envelope, streamID, requestID) })
|
|
}
|
|
}
|
|
|
|
func (s *responsesWSSession) runRequest(state *responsesWSCallState, message []byte, envelope responsesWSCreateEvent, streamID string, requestID string) {
|
|
create, parseErr := normalizeResponsesWSCreateEvent(message, envelope, streamID)
|
|
var apiErr *types.NewAPIError
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
apiErr = types.NewError(fmt.Errorf("responses websocket request panic: %v", recovered), types.ErrorCodeBadResponse, types.ErrOptionWithSkipRetry())
|
|
state.closeAfter = true
|
|
}
|
|
// Finish the middleware count before publishing the terminal event. Hold
|
|
// admission while writing it so an immediate next create cannot race
|
|
// the current request's release; socket close needs neither lock.
|
|
outgoing := state.terminal
|
|
if apiErr != nil {
|
|
if body, err := buildResponsesWSErrorPayload(envelope.EventID, streamID, apiErr); err == nil {
|
|
outgoing = &responsesWSMessage{kind: websocket.TextMessage, body: body}
|
|
}
|
|
}
|
|
s.clientWriteMu.Lock()
|
|
s.stateMu.Lock()
|
|
if outgoing != nil {
|
|
if err := s.client.SetWriteDeadline(time.Now().Add(responsesWSWriteTimeout)); err != nil {
|
|
state.closeAfter = true
|
|
} else if err := s.client.WriteMessage(outgoing.kind, outgoing.body); err != nil {
|
|
state.closeAfter = true
|
|
}
|
|
}
|
|
s.current = nil
|
|
close(state.done)
|
|
s.stateMu.Unlock()
|
|
s.clientWriteMu.Unlock()
|
|
if state.closeAfter {
|
|
s.shutdown()
|
|
}
|
|
}()
|
|
request := s.request.Clone(s.ctx)
|
|
request.Body = io.NopCloser(bytes.NewReader(message))
|
|
request.ContentLength = int64(len(message))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
apiErr = s.runner(request, requestID, func(c *gin.Context) *types.NewAPIError {
|
|
if parseErr != nil {
|
|
return newResponsesWSInvalidRequestError(parseErr)
|
|
}
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(create.Body))
|
|
c.Request.ContentLength = int64(len(create.Body))
|
|
return s.runCall(c, state, create)
|
|
})
|
|
if apiErr != nil && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
|
|
state.closeAfter = true
|
|
}
|
|
}
|
|
|
|
func (s *responsesWSSession) runCall(c *gin.Context, state *responsesWSCallState, create responsesWSCreateRequest) (apiErr *types.NewAPIError) {
|
|
policy := service.RequestPolicy(c)
|
|
modelName := create.Request.Model
|
|
started := time.Now()
|
|
var info *relaycommon.RelayInfo
|
|
billingPrepared := false
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
apiErr = types.NewError(fmt.Errorf("responses websocket call panic: %v", recovered), types.ErrorCodeBadResponse, types.ErrOptionWithSkipRetry())
|
|
state.closeAfter = true
|
|
}
|
|
if info == nil && modelName != "" {
|
|
info = &relaycommon.RelayInfo{OriginModelName: modelName, UsingGroup: common.GetContextKeyString(c, appconstant.ContextKeyUsingGroup), StartTime: started}
|
|
}
|
|
perfmetrics.RecordRelayResult(c.Request.Context(), info, apiErr)
|
|
// Settlement already marks the request policy successful, and nothing
|
|
// reads a termination decision after this point on the WebSocket path,
|
|
// so neither policy record belongs here.
|
|
if info != nil && billingPrepared {
|
|
apiErr = RefundFailedRequestBilling(c, info, apiErr)
|
|
}
|
|
}()
|
|
if modelName == "" {
|
|
return newResponsesWSInvalidRequestError(errors.New("model is required"))
|
|
}
|
|
if s.lockedModel != "" && modelName != s.lockedModel {
|
|
return newResponsesWSInvalidRequestError(fmt.Errorf("responses websocket connection is locked to model %q", s.lockedModel))
|
|
}
|
|
if apiErr = checkResponsesWSModelAccess(c, modelName); apiErr != nil {
|
|
return apiErr
|
|
}
|
|
common.SetContextKey(c, appconstant.ContextKeyOriginalModel, modelName)
|
|
common.SetContextKey(c, appconstant.ContextKeyRequestStartTime, time.Now())
|
|
service.GetChannelConstraints(c).AddFilter(appdto.ChannelFilter{Kind: appdto.FilterRequestPath, RequestPath: c.Request.URL.Path})
|
|
|
|
if s.lockedChannelID != 0 {
|
|
if apiErr = s.restoreConnectionContext(c, modelName); apiErr != nil {
|
|
return apiErr
|
|
}
|
|
info = relaycommon.GenRelayInfoResponses(c, &create.Request)
|
|
info.IsStream = true
|
|
common.SetContextKey(c, appconstant.ContextKeyIsStream, true)
|
|
if apiErr = PrepareRequestBilling(c, info); apiErr != nil {
|
|
return apiErr
|
|
}
|
|
billingPrepared = true
|
|
var payload []byte
|
|
payload, apiErr = buildResponsesWSCreatePayload(c, info, create.Request, create.Generate, create.StreamID)
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
if err := s.writeTarget(websocket.TextMessage, payload); err != nil {
|
|
state.closeAfter = true
|
|
return types.NewError(err, types.ErrorCodeBadResponse, types.ErrOptionWithSkipRetry())
|
|
}
|
|
} else {
|
|
retry := &service.RetryParam{Ctx: c, TokenGroup: common.GetContextKeyString(c, appconstant.ContextKeyUsingGroup), ModelName: modelName, RequestPath: c.Request.URL.Path, Retry: common.GetPointer(0)}
|
|
for ; retry.GetRetry() <= common.RetryTimes; retry.IncreaseRetry() {
|
|
var channel *appmodel.Channel
|
|
channel, apiErr = selectResponsesWSChannel(c, modelName, retry)
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
service.AppendUsedChannel(c, channel.Id)
|
|
if info == nil {
|
|
info = relaycommon.GenRelayInfoResponses(c, &create.Request)
|
|
info.IsStream = true
|
|
common.SetContextKey(c, appconstant.ContextKeyIsStream, true)
|
|
if apiErr = PrepareRequestBilling(c, info); apiErr != nil {
|
|
return apiErr
|
|
}
|
|
billingPrepared = true
|
|
} else {
|
|
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
|
|
if apiErr = service.PrepareTieredBillingForSelectedGroup(c, info); apiErr != nil {
|
|
return apiErr
|
|
}
|
|
}
|
|
info.RetryIndex = retry.GetRetry()
|
|
policy.BeginAttempt(channel, info.UsingGroup)
|
|
var payload []byte
|
|
payload, apiErr = buildResponsesWSCreatePayload(c, info, create.Request, create.Generate, create.StreamID)
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
adaptor := GetAdaptor(info.ApiType)
|
|
adaptor.Init(info)
|
|
target, dialErr := relaychannel.DoWssRequest(adaptor, c, info, nil)
|
|
if dialErr != nil {
|
|
apiErr = service.NormalizeViolationFeeError(types.NewError(dialErr, types.ErrorCodeDoRequestFailed))
|
|
service.ResetStatusCode(apiErr, c.GetString("status_code_mapping"))
|
|
info.LastError = apiErr
|
|
decision := service.DecideRelayRetry(c, apiErr, common.RetryTimes-retry.GetRetry())
|
|
service.RecordPolicyFailure(c, channel.Id, apiErr, decision)
|
|
service.ProcessChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, info.ApiKey, channel.GetAutoBan()), apiErr, info)
|
|
if decision.Action == "retry" {
|
|
continue
|
|
}
|
|
return apiErr
|
|
}
|
|
if !s.setTarget(target) {
|
|
return types.NewError(context.Canceled, types.ErrorCodeBadResponse, types.ErrOptionWithSkipRetry())
|
|
}
|
|
if err := s.writeTarget(websocket.TextMessage, payload); err != nil {
|
|
state.closeAfter = true
|
|
return types.NewError(err, types.ErrorCodeBadResponse, types.ErrOptionWithSkipRetry())
|
|
}
|
|
s.lockedModel, s.lockedChannelID, s.lockedGroup = modelName, channel.Id, info.UsingGroup
|
|
s.lockedKey, s.lockedKeyIndex = info.ApiKey, info.ChannelMultiKeyIndex
|
|
s.lockedRoute, _ = channel.GetOtherSettings().AdvancedCustom.MatchPathForModel(c.Request.URL.Path, modelName)
|
|
s.lockedContext = make(map[appconstant.ContextKey]any)
|
|
for _, key := range []appconstant.ContextKey{
|
|
appconstant.ContextKeyChannelId, appconstant.ContextKeyChannelName, appconstant.ContextKeyChannelType,
|
|
appconstant.ContextKeyChannelCreateTime, appconstant.ContextKeyChannelSetting, appconstant.ContextKeyChannelOtherSetting,
|
|
appconstant.ContextKeyChannelParamOverride, appconstant.ContextKeyChannelHeaderOverride, appconstant.ContextKeyChannelOrganization,
|
|
appconstant.ContextKeyChannelAutoBan, appconstant.ContextKeyChannelModelMapping, appconstant.ContextKeyChannelStatusCodeMapping,
|
|
appconstant.ContextKeyChannelIsMultiKey, appconstant.ContextKeyChannelMultiKeyIndex, appconstant.ContextKeyChannelKey, appconstant.ContextKeyChannelBaseUrl,
|
|
} {
|
|
if value, ok := c.Get(string(key)); ok {
|
|
s.lockedContext[key] = value
|
|
}
|
|
}
|
|
s.registerChannelClose(channel.Id)
|
|
s.startTargetReader(target)
|
|
apiErr = nil
|
|
break
|
|
}
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
}
|
|
|
|
accumulator := service.NewResponsesUsageAccumulator(info)
|
|
info.StreamStatus = relaycommon.NewStreamStatus()
|
|
info.StreamStatus.RequireTerminal()
|
|
common.SetContextKey(c, appconstant.ContextKeyResponseStreamStatus, info.StreamStatus)
|
|
timeout := time.Duration(appconstant.StreamingTimeout) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 300 * time.Second
|
|
}
|
|
idle := time.NewTimer(timeout)
|
|
defer idle.Stop()
|
|
accepted := false
|
|
var pendingControl []byte
|
|
var sentControl []byte
|
|
var responseID string
|
|
for {
|
|
select {
|
|
case incoming := <-state.inbox:
|
|
idle.Reset(timeout)
|
|
if incoming.err != nil {
|
|
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonScannerErr, incoming.err)
|
|
state.closeAfter = true
|
|
ConsumeResponsesQuota(c, info, accumulator.Finish())
|
|
return nil
|
|
}
|
|
info.SetFirstResponseTime()
|
|
var event struct {
|
|
dto.ResponsesStreamResponse
|
|
StreamID string `json:"stream_id"`
|
|
}
|
|
if err := common.Unmarshal(incoming.body, &event); err != nil {
|
|
info.StreamStatus.RecordError("invalid upstream websocket event")
|
|
} else {
|
|
if event.Type != "error" && event.StreamID != "" && event.StreamID != create.StreamID {
|
|
if err := s.writeClient(incoming.kind, incoming.body); err != nil {
|
|
s.shutdown()
|
|
}
|
|
continue
|
|
}
|
|
// A repeated terminal from the previous response must never finish
|
|
// a subsequent request on this persistent connection.
|
|
if event.Response != nil && event.Response.ID != "" && event.Response.ID == s.lastResponseID {
|
|
continue
|
|
}
|
|
if event.Type == "error" {
|
|
var rejection responsesWSErrorEvent
|
|
_ = common.Unmarshal(incoming.body, &rejection)
|
|
if rejection.ResponseID != "" && rejection.ResponseID == s.lastResponseID {
|
|
continue
|
|
}
|
|
terminal, ambiguous, controlError := responsesWSErrorEndsRequest(rejection, create.StreamID, responseID, sentControl)
|
|
if !terminal {
|
|
if err := s.writeClient(incoming.kind, incoming.body); err != nil {
|
|
s.shutdown()
|
|
}
|
|
// Only a control error in this stream resolves its pending control.
|
|
if controlError {
|
|
sentControl = nil
|
|
}
|
|
continue
|
|
}
|
|
if accepted {
|
|
if rejection.Error != nil {
|
|
code := ""
|
|
if rejection.Error.Code != nil {
|
|
code = fmt.Sprint(rejection.Error.Code)
|
|
}
|
|
info.StreamStatus.MarkFailed(code, rejection.Error.Type, rejection.Status)
|
|
}
|
|
accumulator.Observe(&event.ResponsesStreamResponse)
|
|
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
|
s.lastResponseID = responseID
|
|
state.terminal, state.closeAfter = &incoming, ambiguous
|
|
ConsumeResponsesQuota(c, info, accumulator.Finish())
|
|
return nil
|
|
}
|
|
if rejection.Error == nil {
|
|
// An error frame without an error object still describes the
|
|
// rejected request; keep the client-facing type stable.
|
|
rejection.Error = &types.OpenAIError{Type: "invalid_request_error", Message: event.Message, Code: event.Code}
|
|
}
|
|
rejected := types.WithOpenAIError(*rejection.Error, rejection.Status, types.ErrOptionWithSkipRetry())
|
|
if rejected.StatusCode < 400 || rejected.StatusCode > 599 {
|
|
rejected.StatusCode = http.StatusBadRequest
|
|
}
|
|
return rejected
|
|
}
|
|
if strings.HasPrefix(event.Type, "response.") {
|
|
if !accepted {
|
|
// Like HTTP, bind the session only once upstream accepted the request.
|
|
service.RecordChannelAffinity(c, s.lockedChannelID)
|
|
}
|
|
accepted = true
|
|
if event.Response != nil && event.Response.ID != "" {
|
|
responseID = event.Response.ID
|
|
}
|
|
}
|
|
accumulator.Observe(&event.ResponsesStreamResponse)
|
|
}
|
|
switch event.Type {
|
|
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled":
|
|
if event.Response != nil {
|
|
s.lastResponseID = event.Response.ID
|
|
}
|
|
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
|
state.terminal = &incoming
|
|
ConsumeResponsesQuota(c, info, accumulator.Finish())
|
|
return nil
|
|
}
|
|
if err := s.writeClient(incoming.kind, incoming.body); err != nil {
|
|
s.shutdown()
|
|
}
|
|
if accepted && pendingControl != nil {
|
|
if err := s.writeTarget(websocket.TextMessage, pendingControl); err != nil {
|
|
s.shutdown()
|
|
}
|
|
sentControl = pendingControl
|
|
pendingControl = nil
|
|
}
|
|
case control := <-state.controls:
|
|
if pendingControl != nil || sentControl != nil {
|
|
s.sendError(control.eventID, control.streamID, newResponsesWSInvalidRequestError(errors.New("a response control event is already pending")))
|
|
continue
|
|
}
|
|
if !accepted {
|
|
pendingControl = control.body
|
|
continue
|
|
}
|
|
if err := s.writeTarget(websocket.TextMessage, control.body); err != nil {
|
|
s.shutdown()
|
|
}
|
|
sentControl = control.body
|
|
case <-idle.C:
|
|
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, context.DeadlineExceeded)
|
|
state.closeAfter = true
|
|
ConsumeResponsesQuota(c, info, accumulator.Finish())
|
|
return nil
|
|
case <-s.ctx.Done():
|
|
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, s.ctx.Err())
|
|
ConsumeResponsesQuota(c, info, accumulator.Finish())
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// A single generation is active, but a cancel can fail independently. Correlate
|
|
// available IDs first; uncorrelated control/server errors require a bounded
|
|
// connection close because continuing could leave the generation stuck forever.
|
|
func responsesWSErrorEndsRequest(event responsesWSErrorEvent, streamID, responseID string, control []byte) (terminal, ambiguous, controlError bool) {
|
|
if len(control) > 0 {
|
|
var pending struct {
|
|
EventID string `json:"event_id"`
|
|
StreamID string `json:"stream_id"`
|
|
ResponseID string `json:"response_id"`
|
|
}
|
|
_ = common.Unmarshal(control, &pending)
|
|
if event.EventID != "" && event.EventID == pending.EventID {
|
|
return false, false, true
|
|
}
|
|
if event.ResponseID != "" && event.ResponseID == pending.ResponseID && event.ResponseID != responseID {
|
|
return false, false, true
|
|
}
|
|
if (event.StreamID == "" || event.StreamID == pending.StreamID) && event.Error != nil && event.Error.Type == "invalid_request_error" {
|
|
switch event.Error.Code {
|
|
case "response_not_found", "response_not_active", "response_already_completed":
|
|
return false, false, true
|
|
}
|
|
}
|
|
}
|
|
if event.StreamID != "" && event.StreamID != streamID || responseID != "" && event.ResponseID != "" && event.ResponseID != responseID {
|
|
return false, false, false
|
|
}
|
|
return true, len(control) > 0 && event.ResponseID == "", false
|
|
}
|
|
|
|
func (s *responsesWSSession) restoreConnectionContext(c *gin.Context, model string) *types.NewAPIError {
|
|
// The channel type is not rechecked: a locked channel keeps its type, and
|
|
// the FilterResponsesWebSocket rules were applied when it was selected.
|
|
channel, err := appmodel.CacheGetChannel(s.lockedChannelID)
|
|
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled || !channel.GetSetting().ResponsesWebSocketEnabled {
|
|
return types.NewErrorWithStatusCode(errors.New("Responses WebSocket is disabled for this channel"), types.ErrorCode(appdto.FilterResponsesWebSocket), http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
keyEnabled := channel.Key == s.lockedKey
|
|
if channel.ChannelInfo.IsMultiKey {
|
|
keys := channel.GetKeys()
|
|
status := channel.ChannelInfo.MultiKeyStatusList[s.lockedKeyIndex]
|
|
keyEnabled = s.lockedKeyIndex >= 0 && s.lockedKeyIndex < len(keys) && keys[s.lockedKeyIndex] == s.lockedKey && (status == 0 || status == common.ChannelStatusEnabled)
|
|
}
|
|
if !keyEnabled {
|
|
return types.NewErrorWithStatusCode(errors.New("the upstream connection credential is no longer enabled"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
// Changes that alter the physical upstream connection require a new
|
|
// handshake. Request-level settings can be refreshed without rotating keys.
|
|
if channel.GetBaseURL() != s.lockedContext[appconstant.ContextKeyChannelBaseUrl] ||
|
|
!reflect.DeepEqual(channel.GetHeaderOverride(), s.lockedContext[appconstant.ContextKeyChannelHeaderOverride]) {
|
|
return types.NewErrorWithStatusCode(errors.New("upstream connection settings changed; reconnect required"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
if previous, ok := s.lockedContext[appconstant.ContextKeyChannelSetting].(dto.ChannelSettings); ok && previous.Proxy != channel.GetSetting().Proxy {
|
|
return types.NewErrorWithStatusCode(errors.New("upstream proxy changed; reconnect required"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
// An advanced custom connection was dialed through the route matched for
|
|
// this model. Only the fields that shape the upstream handshake force a
|
|
// reconnect: target path, protocol conversion and credential placement.
|
|
// Other channel types match the zero route on both sides.
|
|
route, _ := channel.GetOtherSettings().AdvancedCustom.MatchPathForModel(c.Request.URL.Path, model)
|
|
if strings.TrimSpace(route.UpstreamPath) != strings.TrimSpace(s.lockedRoute.UpstreamPath) ||
|
|
route.IsNative() != s.lockedRoute.IsNative() ||
|
|
!reflect.DeepEqual(route.Auth, s.lockedRoute.Auth) {
|
|
return types.NewErrorWithStatusCode(errors.New("upstream route changed; reconnect required"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
if pin, found, _ := service.GetChannelConstraints(c).ResolvedPin(); found {
|
|
if pin.ChannelId != s.lockedChannelID {
|
|
return types.NewErrorWithStatusCode(errors.New("channel pin changed; reconnect required"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
} else {
|
|
group := common.GetContextKeyString(c, appconstant.ContextKeyUsingGroup)
|
|
if group == "auto" {
|
|
if !slices.Contains(service.GetRequestAutoGroups(c, common.GetContextKeyString(c, appconstant.ContextKeyUserGroup)), s.lockedGroup) {
|
|
return types.NewErrorWithStatusCode(errors.New("the connection group is no longer allowed"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
group = s.lockedGroup
|
|
common.SetContextKey(c, appconstant.ContextKeyAutoGroup, group)
|
|
}
|
|
if !appmodel.IsChannelEnabledForGroupModel(group, model, s.lockedChannelID) {
|
|
return types.NewErrorWithStatusCode(errors.New("the connection channel is no longer allowed for this group and model"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
}
|
|
for key, value := range s.lockedContext {
|
|
c.Set(string(key), value)
|
|
}
|
|
common.SetContextKey(c, appconstant.ContextKeyChannelSetting, channel.GetSetting())
|
|
common.SetContextKey(c, appconstant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
|
|
common.SetContextKey(c, appconstant.ContextKeyChannelParamOverride, channel.GetParamOverride())
|
|
common.SetContextKey(c, appconstant.ContextKeyChannelModelMapping, channel.GetModelMapping())
|
|
common.SetContextKey(c, appconstant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping())
|
|
policy := service.RequestPolicy(c)
|
|
policy.BeginAttempt(channel, s.lockedGroup)
|
|
policy.AddEvent(service.PolicyEvent{ChannelID: channel.Id, Decision: service.PolicyDecision{Action: "select", Reason: "pinned_channel", Source: "channel_constraint"}})
|
|
return nil
|
|
}
|
|
|
|
func buildResponsesWSCreatePayload(c *gin.Context, info *relaycommon.RelayInfo, req dto.OpenAIResponsesRequest, generate common.RawMessage, streamID string) ([]byte, *types.NewAPIError) {
|
|
_, body, closer, apiErr := PrepareResponsesRequest(c, info, &req)
|
|
if apiErr != nil {
|
|
return nil, apiErr
|
|
}
|
|
defer closer.Close()
|
|
jsonData, err := io.ReadAll(body)
|
|
if err != nil {
|
|
return nil, types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
|
|
}
|
|
event, err := buildResponsesWSCreateEvent(jsonData, generate, streamID)
|
|
if err != nil {
|
|
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
func (s *responsesWSSession) startTargetReader(target *websocket.Conn) {
|
|
limit := int64(helper.DefaultMaxScannerBufferSize)
|
|
if appconstant.StreamScannerMaxBufferMB > 0 {
|
|
limit = int64(appconstant.StreamScannerMaxBufferMB) << 20
|
|
}
|
|
target.SetReadLimit(limit)
|
|
s.workers.Go(func() {
|
|
for {
|
|
kind, body, err := target.ReadMessage()
|
|
incoming := responsesWSMessage{kind: kind, body: body, err: err}
|
|
if state := s.getCurrent(); state != nil {
|
|
select {
|
|
case state.inbox <- incoming:
|
|
case <-state.done:
|
|
if err == nil {
|
|
if writeErr := s.writeClient(kind, body); writeErr != nil {
|
|
s.shutdown()
|
|
return
|
|
}
|
|
} else {
|
|
s.shutdown()
|
|
}
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
} else if err == nil {
|
|
if writeErr := s.writeClient(kind, body); writeErr != nil {
|
|
s.shutdown()
|
|
return
|
|
}
|
|
} else {
|
|
s.shutdown()
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *responsesWSSession) getCurrent() *responsesWSCallState {
|
|
s.stateMu.Lock()
|
|
defer s.stateMu.Unlock()
|
|
return s.current
|
|
}
|
|
|
|
func (s *responsesWSSession) tryReserveCurrent(state *responsesWSCallState) bool {
|
|
s.stateMu.Lock()
|
|
defer s.stateMu.Unlock()
|
|
if s.current != nil {
|
|
return false
|
|
}
|
|
s.current = state
|
|
return true
|
|
}
|
|
|
|
func (s *responsesWSSession) getTarget() *websocket.Conn {
|
|
s.connectionMu.Lock()
|
|
defer s.connectionMu.Unlock()
|
|
return s.target
|
|
}
|
|
|
|
func (s *responsesWSSession) setTarget(target *websocket.Conn) bool {
|
|
s.connectionMu.Lock()
|
|
defer s.connectionMu.Unlock()
|
|
if s.ctx.Err() != nil {
|
|
_ = target.Close()
|
|
return false
|
|
}
|
|
s.target = target
|
|
return true
|
|
}
|
|
|
|
func (s *responsesWSSession) writeTarget(kind int, message []byte) error {
|
|
s.targetWriteMu.Lock()
|
|
defer s.targetWriteMu.Unlock()
|
|
target := s.getTarget()
|
|
if target == nil {
|
|
return errors.New("responses websocket upstream is not connected")
|
|
}
|
|
if err := target.SetWriteDeadline(time.Now().Add(responsesWSWriteTimeout)); err != nil {
|
|
return err
|
|
}
|
|
return target.WriteMessage(kind, message)
|
|
}
|
|
|
|
func (s *responsesWSSession) writeClient(kind int, message []byte) error {
|
|
s.clientWriteMu.Lock()
|
|
defer s.clientWriteMu.Unlock()
|
|
if err := s.client.SetWriteDeadline(time.Now().Add(responsesWSWriteTimeout)); err != nil {
|
|
return err
|
|
}
|
|
return s.client.WriteMessage(kind, message)
|
|
}
|
|
|
|
func (s *responsesWSSession) sendError(eventID, streamID string, apiErr *types.NewAPIError) {
|
|
if apiErr == nil {
|
|
return
|
|
}
|
|
payload, err := buildResponsesWSErrorPayload(eventID, streamID, apiErr)
|
|
if err == nil {
|
|
_ = s.writeClient(websocket.TextMessage, payload)
|
|
}
|
|
}
|
|
|
|
func (s *responsesWSSession) closeTarget() {
|
|
s.connectionMu.Lock()
|
|
target, unregister := s.target, s.unregister
|
|
s.target, s.unregister = nil, nil
|
|
s.connectionMu.Unlock()
|
|
if unregister != nil {
|
|
unregister()
|
|
}
|
|
if target != nil {
|
|
_ = target.Close()
|
|
}
|
|
}
|
|
|
|
func (s *responsesWSSession) shutdown() {
|
|
s.cancel()
|
|
s.closeTarget()
|
|
_ = s.client.Close()
|
|
}
|
|
|
|
func (s *responsesWSSession) registerChannelClose(channelID int) {
|
|
unregister := wsmanager.Register(channelID, wsmanager.KindResponses, s.closeForPolicy)
|
|
s.connectionMu.Lock()
|
|
if s.ctx.Err() != nil {
|
|
s.connectionMu.Unlock()
|
|
unregister()
|
|
return
|
|
}
|
|
s.unregister = unregister
|
|
s.connectionMu.Unlock()
|
|
}
|
|
|
|
// closeForPolicy tells both peers why the connection ends, like the realtime
|
|
// relay does; WriteControl is safe alongside a blocked data writer.
|
|
func (s *responsesWSSession) closeForPolicy(reason string) {
|
|
closeMessage := websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason)
|
|
deadline := time.Now().Add(time.Second)
|
|
_ = s.client.WriteControl(websocket.CloseMessage, closeMessage, deadline)
|
|
if target := s.getTarget(); target != nil {
|
|
_ = target.WriteControl(websocket.CloseMessage, closeMessage, deadline)
|
|
}
|
|
s.shutdown()
|
|
}
|
|
|
|
// Stream identity belongs to the WebSocket envelope. For the legacy wrapped
|
|
// input, the top-level field takes precedence over response.stream_id.
|
|
func parseResponsesWSEnvelope(message []byte) (responsesWSCreateEvent, string, error) {
|
|
var event responsesWSCreateEvent
|
|
if err := common.Unmarshal(message, &event); err != nil {
|
|
return event, "", fmt.Errorf("invalid websocket event json: %w", err)
|
|
}
|
|
streamRaw := event.StreamID
|
|
if len(streamRaw) == 0 && len(event.Request) > 0 {
|
|
var wrapped struct {
|
|
StreamID common.RawMessage `json:"stream_id"`
|
|
}
|
|
if err := common.Unmarshal(event.Request, &wrapped); err == nil {
|
|
streamRaw = wrapped.StreamID
|
|
}
|
|
}
|
|
var streamID string
|
|
if len(streamRaw) > 0 {
|
|
if err := common.Unmarshal(streamRaw, &streamID); err != nil || len(streamID) < 1 || len(streamID) > 256 {
|
|
return event, "", errors.New("stream_id must contain 1-256 ASCII letters, digits, underscores, hyphens, or periods")
|
|
}
|
|
for _, char := range streamID {
|
|
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' || char == '-' || char == '.') {
|
|
return event, "", errors.New("stream_id must contain only ASCII letters, digits, underscores, hyphens, or periods")
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(event.Type) == "" {
|
|
return event, streamID, errors.New("websocket event type is required")
|
|
}
|
|
return event, streamID, nil
|
|
}
|
|
|
|
func newResponsesWSInvalidRequestError(err error) *types.NewAPIError {
|
|
return types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
|
}
|
|
|
|
// normalizeResponsesWSCreateEvent builds the HTTP-shaped request body from a
|
|
// response.create whose envelope the read loop already parsed and validated.
|
|
// The message is decoded once more as a raw map (the body source) and once as
|
|
// the typed request; the envelope is not parsed again.
|
|
func normalizeResponsesWSCreateEvent(message []byte, event responsesWSCreateEvent, streamID string) (responsesWSCreateRequest, error) {
|
|
create := responsesWSCreateRequest{StreamID: streamID, Generate: event.Generate}
|
|
// Decode the wrapped request on its own so outer fields cannot enter the HTTP body.
|
|
source := message
|
|
if len(event.Request) > 0 {
|
|
source = event.Request
|
|
}
|
|
var raw map[string]common.RawMessage
|
|
if err := common.Unmarshal(source, &raw); err != nil {
|
|
return create, err
|
|
}
|
|
if len(event.Request) > 0 {
|
|
if len(create.Generate) == 0 {
|
|
create.Generate = raw["generate"]
|
|
}
|
|
} else {
|
|
for _, key := range []string{"type", "event_id", "background", "stream", "stream_options"} {
|
|
delete(raw, key)
|
|
}
|
|
}
|
|
delete(raw, "generate")
|
|
delete(raw, "stream_id")
|
|
payload, err := common.Marshal(raw)
|
|
if err != nil {
|
|
return create, err
|
|
}
|
|
if err := common.Unmarshal(payload, &create.Request); err != nil {
|
|
return create, err
|
|
}
|
|
if helper.ExceedsMaxTokensLimit(create.Request.MaxOutputTokens) {
|
|
return create, errors.New("max_output_tokens is invalid")
|
|
}
|
|
create.Request.Stream = nil
|
|
create.Request.StreamOptions = nil
|
|
create.Body = payload
|
|
return create, nil
|
|
}
|
|
|
|
func buildResponsesWSCreateEvent(jsonData []byte, generate common.RawMessage, streamID string) ([]byte, error) {
|
|
var event map[string]common.RawMessage
|
|
if err := common.Unmarshal(jsonData, &event); err != nil {
|
|
return nil, err
|
|
}
|
|
typeData, err := common.Marshal(responsesWSEventTypeResponseCreate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
event["type"] = typeData
|
|
// The normalized body never carries stream_id; a channel parameter
|
|
// override may inject one, and it must not shadow the envelope's value.
|
|
delete(event, "stream_id")
|
|
if streamID != "" {
|
|
streamData, err := common.Marshal(streamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
event["stream_id"] = streamData
|
|
}
|
|
delete(event, "event_id")
|
|
delete(event, "background")
|
|
delete(event, "stream")
|
|
delete(event, "stream_options")
|
|
if len(generate) > 0 {
|
|
event["generate"] = generate
|
|
}
|
|
return common.Marshal(event)
|
|
}
|
|
|
|
func buildResponsesWSErrorPayload(eventID, streamID string, apiErr *types.NewAPIError) ([]byte, error) {
|
|
if apiErr == nil {
|
|
return nil, errors.New("api error is nil")
|
|
}
|
|
status := apiErr.StatusCode
|
|
if status == 0 {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
openaiErr := apiErr.ToOpenAIError()
|
|
return common.Marshal(&responsesWSErrorEvent{
|
|
Type: "error",
|
|
Status: status,
|
|
EventID: eventID,
|
|
StreamID: streamID,
|
|
Error: &openaiErr,
|
|
})
|
|
}
|
|
|
|
// checkResponsesWSModelAccess applies the token model limit with the same name
|
|
// matching as the HTTP distributor. Unlike HTTP, it also runs for requests
|
|
// pinned to a channel: the check is stricter there on purpose, because a
|
|
// persistent connection keeps serving the model after the pin was resolved.
|
|
func checkResponsesWSModelAccess(c *gin.Context, modelName string) *types.NewAPIError {
|
|
if !common.GetContextKeyBool(c, appconstant.ContextKeyTokenModelLimitEnabled) {
|
|
return nil
|
|
}
|
|
raw, ok := common.GetContextKey(c, appconstant.ContextKeyTokenModelLimit)
|
|
if !ok {
|
|
return types.NewErrorWithStatusCode(errors.New("token has no model access"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
tokenModelLimit, ok := raw.(map[string]bool)
|
|
if !ok {
|
|
tokenModelLimit = map[string]bool{}
|
|
}
|
|
if !middleware.TokenModelLimitAllows(tokenModelLimit, modelName) {
|
|
return types.NewErrorWithStatusCode(fmt.Errorf("token is not allowed to use model %s", modelName), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// selectResponsesWSChannel narrows the shared HTTP selection to channels that
|
|
// speak the Responses WebSocket protocol and renders its outcome as a
|
|
// non-retryable NewAPIError.
|
|
func selectResponsesWSChannel(c *gin.Context, modelName string, retryParam *service.RetryParam) (*appmodel.Channel, *types.NewAPIError) {
|
|
constraints := service.GetChannelConstraints(c)
|
|
if !slices.ContainsFunc(constraints.Filters, func(filter appdto.ChannelFilter) bool {
|
|
return filter.Kind == appdto.FilterResponsesWebSocket
|
|
}) {
|
|
constraints.AddFilter(appdto.ChannelFilter{Kind: appdto.FilterResponsesWebSocket})
|
|
}
|
|
channel, _, selectErr := service.SelectChannelForRequest(c, modelName, retryParam)
|
|
if selectErr != nil {
|
|
message := selectErr.Message
|
|
if selectErr.MessageID != "" {
|
|
message = i18n.T(c, selectErr.MessageID, selectErr.Params)
|
|
}
|
|
code := selectErr.Code
|
|
if code == "" {
|
|
code = types.ErrorCodeGetChannelFailed
|
|
}
|
|
return nil, types.NewErrorWithStatusCode(errors.New(message), code, selectErr.StatusCode, types.ErrOptionWithSkipRetry())
|
|
}
|
|
if err := middleware.SetupContextForSelectedChannel(c, channel, modelName); err != nil {
|
|
return nil, err
|
|
}
|
|
return channel, nil
|
|
}
|