package live import ( "context" "encoding/json" "net/http" "net/url" "strings" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" ) const defaultStandardRealtimeModel = "gpt-realtime" // HandleRealtimeWebsocket dispatches a standard Realtime WebSocket or an existing call sideband. func (h *Handler) HandleRealtimeWebsocket(c *gin.Context) { if strings.TrimSpace(c.Query("call_id")) != "" { h.HandleSideband(c) return } h.HandleDirectWebsocket(c) } // HandleDirectWebsocket relays a standard Realtime WebSocket through Codex OAuth. func (h *Handler) HandleDirectWebsocket(c *gin.Context) { if h == nil || h.authManager == nil { writeRealtimeError(c, http.StatusServiceUnavailable, "Codex auth manager unavailable", "server_error", "codex_auth_unavailable") return } if !websocket.IsWebSocketUpgrade(c.Request) { c.Header("Upgrade", "websocket") writeRealtimeError(c, http.StatusUpgradeRequired, "WebSocket upgrade required", "invalid_request_error", "websocket_upgrade_required") return } requestedModel := strings.TrimSpace(c.Query("model")) if requestedModel == "" { requestedModel = defaultStandardRealtimeModel } selectionModel := codexRealtimeModel(requestedModel) tokenSession := clientSecretSession(c) if len(tokenSession) > 0 { tokenModel := codexRealtimeModel(modelFromJSON(tokenSession)) if selectionModel != tokenModel { writeRealtimeError(c, http.StatusForbidden, "Realtime client secret is not valid for the requested model", "invalid_request_error", "realtime_client_secret_scope_mismatch") return } } ctx := context.WithValue(c.Request.Context(), "gin", c) ctx = coreexecutor.WithDownstreamWebsocket(ctx) selectionOpts := coreexecutor.Options{Headers: liveSelectionHeaders(c)} selection, selected, errSelect := h.selectOAuth(ctx, selectionModel, selectionOpts) if errSelect != nil { writeSelectionError(c, errSelect) return } if selected == nil { if selection != nil { selection.End("missing_auth") } writeRealtimeError(c, http.StatusServiceUnavailable, "Codex auth unavailable", "server_error", "codex_auth_unavailable") return } if selection != nil { attemptCtx, releaseAttempt, errAttempt := selection.AttemptContext(ctx) if errAttempt != nil { selection.End("attempt_bind_failed") writeRealtimeError(c, http.StatusServiceUnavailable, errAttempt.Error(), "server_error", "realtime_upstream_unavailable") return } ctx = attemptCtx defer releaseAttempt() selection.Retain() defer selection.End("session_closed") } logging.SetGinCPATraceID(c, selected.EnsureIndex()) upstreamURL := h.directRealtimeURL(requestedModel) dialUpstream := func(current *auth.Auth) (*websocket.Conn, *http.Response, error) { request, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, websocketHTTPURL(upstreamURL), nil) if errRequest != nil { return nil, nil, errRequest } request.Header = directRealtimeHeaders(c.Request.Header) setAccountHeader(request.Header, current) if errPrepare := h.authManager.PrepareHttpRequest(ctx, current, request); errPrepare != nil { return nil, nil, errPrepare } authType, authValue := current.AccountInfo() helpersConfig := h.currentConfig() helps.RecordAPIWebsocketRequest(ctx, helpersConfig, helps.UpstreamRequestLog{ URL: upstreamURL, Method: "WEBSOCKET", Headers: headersForLogging(request.Header), Provider: "codex", AuthID: current.ID, AuthLabel: current.Label, AuthType: authType, AuthValue: authValue, }) dialer := newProxyAwareSidebandDialer(helpersConfig, current) dialer.Subprotocols = websocket.Subprotocols(c.Request) return dialer.DialContext(ctx, upstreamURL, request.Header) } upstream, handshakeResponse, errDial := dialUpstream(selected) if errDial != nil && selection != nil && handshakeResponse != nil && handshakeResponse.StatusCode == http.StatusUnauthorized { h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel) closeHandshakeBody(handshakeResponse, "direct websocket unauthorized") refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected) if errRefresh != nil { writeSelectionError(c, errRefresh) return } if didRefresh && refreshed != nil { selected = refreshed logging.SetGinCPATraceID(c, selected.EnsureIndex()) upstream, handshakeResponse, errDial = dialUpstream(selected) } } if errDial != nil { status := clienterror.HTTPStatusFromErrorOr(errDial, http.StatusBadGateway) if handshakeResponse != nil && handshakeResponse.StatusCode > 0 { status = handshakeResponse.StatusCode copyRealtimeHandshakeHeaders(c.Writer.Header(), handshakeResponse.Header) } closeHandshakeBody(handshakeResponse, "direct websocket rejected") helpConfig := h.currentConfig() helpDetails := "Codex Realtime WebSocket upstream unavailable" helpType := "api_error" if status == http.StatusNotFound || status == http.StatusNotImplemented { helpDetails = "Direct Realtime WebSocket is not supported by the Codex OAuth upstream" helpType = "not_supported_error" status = http.StatusNotImplemented } helpCode := "realtime_websocket_upstream_unavailable" if helpType == "not_supported_error" { helpCode = "realtime_capability_not_supported" } else if status == http.StatusUnauthorized { helpType = "authentication_error" helpCode = "realtime_upstream_unauthorized" } helps.RecordAPIWebsocketError(ctx, helpConfig, "dial", errDial) writeRealtimeError(c, status, helpDetails, helpType, helpCode) return } closeHandshakeBody(handshakeResponse, "direct websocket handshake") closeUpstream := websocketCloseFunc("upstream", upstream) defer func() { _ = closeUpstream() }() if len(tokenSession) > 0 { updateSession, errSession := realtimeSessionUpdate(tokenSession) if errSession != nil { _ = closeUpstream() writeRealtimeError(c, http.StatusInternalServerError, "Failed to apply Realtime client secret session", "server_error", "realtime_session_failed") return } update, errMarshal := json.Marshal(struct { Type string `json:"type"` Session json.RawMessage `json:"session"` }{Type: "session.update", Session: updateSession}) if errMarshal != nil { _ = closeUpstream() writeRealtimeError(c, http.StatusInternalServerError, "Failed to apply Realtime client secret session", "server_error", "realtime_session_failed") return } if errWrite := upstream.WriteMessage(websocket.TextMessage, update); errWrite != nil { _ = closeUpstream() writeRealtimeError(c, http.StatusBadGateway, "Failed to apply Realtime client secret session", "api_error", "realtime_upstream_unavailable") return } } if selection != nil { if errBind := selection.Bind(closeUpstream); errBind != nil { writeRealtimeError(c, http.StatusServiceUnavailable, errBind.Error(), "server_error", "realtime_upstream_unavailable") return } } upgradeHeaders := make(http.Header) if subprotocol := upstream.Subprotocol(); subprotocol != "" { upgradeHeaders.Set("Sec-WebSocket-Protocol", subprotocol) } downstream, errUpgrade := sidebandUpgrader.Upgrade(c.Writer, c.Request, upgradeHeaders) if errUpgrade != nil { _ = closeUpstream() return } closeDownstream := websocketCloseFunc("downstream", downstream) defer func() { _ = closeDownstream() }() if selection != nil { if errBind := selection.Bind(closeDownstream); errBind != nil { return } } if errRelay := relayWebsockets(downstream, upstream); errRelay != nil && !isNormalWebsocketClose(errRelay) { helps.RecordAPIWebsocketError(ctx, h.currentConfig(), "relay", errRelay) log.WithError(errRelay).Debug("codex realtime direct websocket relay closed") } } func realtimeSessionUpdate(session json.RawMessage) (json.RawMessage, error) { var update map[string]json.RawMessage if errUnmarshal := json.Unmarshal(session, &update); errUnmarshal != nil { return nil, errUnmarshal } for _, field := range []string{"model", "id", "object", "expires_at", "client_secret"} { delete(update, field) } return json.Marshal(update) } func (h *Handler) directRealtimeURL(model string) string { values := make(url.Values) values.Set("model", strings.TrimSpace(model)) return strings.TrimRight(h.sidebandAPIBaseURL, "/") + "/realtime?" + values.Encode() } func directRealtimeHeaders(source http.Header) http.Header { headers := protocolHeaders(source) headers.Del("OpenAI-Alpha") if headers.Get("Originator") == "" { headers.Set("Originator", "Codex Desktop") } return headers } func copyRealtimeHandshakeHeaders(destination, source http.Header) { for _, name := range []string{"Retry-After", "X-Request-Id", "OpenAI-Request-Id"} { for _, value := range source.Values(name) { destination.Add(name, value) } } } func closeHandshakeBody(response *http.Response, label string) { if response == nil || response.Body == nil { return } if errClose := response.Body.Close(); errClose != nil { log.Errorf("codex realtime: close %s response body error: %v", label, errClose) } }