vibe-proxy/backend/internal/client/codex/live/websocket.go
2026-08-24 00:10:41 +02:00

251 lines
9.3 KiB
Go

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)
}
}