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 }