251 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|