840 lines
27 KiB
Go
840 lines
27 KiB
Go
// Package live forwards Codex realtime WebRTC session bootstrap requests.
|
|
package live
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"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 (
|
|
upstreamCallURL = "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"
|
|
defaultLiveModel = "gpt-live-1-codex"
|
|
maxBodySize = 16 << 20
|
|
)
|
|
|
|
var liveProtocolHeaders = []string{
|
|
"OpenAI-Alpha",
|
|
"X-Session-Id",
|
|
"Session-Id",
|
|
"Thread-Id",
|
|
"Originator",
|
|
"OpenAI-Safety-Identifier",
|
|
"OpenAI-Organization",
|
|
"OpenAI-Project",
|
|
"X-Oai-Attestation",
|
|
}
|
|
|
|
// Handler forwards Codex live session requests through the shared auth scheduler.
|
|
type Handler struct {
|
|
authManager *auth.Manager
|
|
cfg *config.Config
|
|
sessions *sessionStore
|
|
clientSecrets *clientSecretStore
|
|
sidebandAPIBaseURL string
|
|
mediaRelayMu sync.RWMutex
|
|
mediaRelay mediaRelayFactory
|
|
mediaRelayErr error
|
|
mediaRelayConfig config.CodexLiveMediaRelayConfig
|
|
mediaRelayConfigured bool
|
|
mediaLimiter *mediaSessionLimiter
|
|
}
|
|
|
|
// NewHandler creates a Codex live session handler.
|
|
func NewHandler(authManager *auth.Manager, cfg *config.Config) *Handler {
|
|
handler := &Handler{
|
|
authManager: authManager,
|
|
cfg: cfg,
|
|
sessions: newSessionStore(),
|
|
clientSecrets: newClientSecretStore(),
|
|
sidebandAPIBaseURL: defaultSidebandAPIBaseURL,
|
|
}
|
|
if errUpdate := handler.UpdateConfig(cfg); errUpdate != nil {
|
|
log.WithError(errUpdate).Error("failed to configure Codex Live media relay")
|
|
}
|
|
return handler
|
|
}
|
|
|
|
// UpdateConfig atomically applies Codex Live media relay settings to new sessions.
|
|
func (h *Handler) UpdateConfig(cfg *config.Config) error {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
var relayConfig config.CodexLiveMediaRelayConfig
|
|
if cfg != nil {
|
|
relayConfig = cfg.Codex.LiveMediaRelay
|
|
}
|
|
h.mediaRelayMu.Lock()
|
|
previousConfig := h.mediaRelayConfig
|
|
previouslyConfigured := h.mediaRelayConfigured
|
|
h.cfg = cfg
|
|
if previouslyConfigured && reflect.DeepEqual(previousConfig, relayConfig) {
|
|
currentErr := h.mediaRelayErr
|
|
h.mediaRelayMu.Unlock()
|
|
return currentErr
|
|
}
|
|
if h.mediaLimiter == nil {
|
|
h.mediaLimiter = &mediaSessionLimiter{}
|
|
}
|
|
var relay mediaRelayFactory
|
|
var relayErr error
|
|
if relayConfig.Enabled {
|
|
relay, relayErr = newPionMediaRelayWithLimiter(relayConfig, h.mediaLimiter)
|
|
}
|
|
h.mediaRelay = relay
|
|
h.mediaRelayErr = relayErr
|
|
h.mediaRelayConfig = relayConfig
|
|
h.mediaRelayConfigured = true
|
|
h.mediaRelayMu.Unlock()
|
|
|
|
if relayErr == nil && (previouslyConfigured || relayConfig.Enabled) {
|
|
message := "codex live media relay configured"
|
|
if previouslyConfigured {
|
|
message = "codex live media relay configuration reloaded; changes apply to new sessions"
|
|
}
|
|
log.WithFields(liveMediaConfigLogFields(relayConfig)).Info(message)
|
|
}
|
|
return relayErr
|
|
}
|
|
|
|
func liveMediaConfigLogFields(relayConfig config.CodexLiveMediaRelayConfig) log.Fields {
|
|
publicIP := strings.TrimSpace(relayConfig.PublicIP)
|
|
if publicIP == "" {
|
|
publicIP = "auto"
|
|
}
|
|
return log.Fields{
|
|
"enabled": relayConfig.Enabled,
|
|
"max_sessions": relayConfig.EffectiveMaxSessions(),
|
|
"disable_private_remote_ips": relayConfig.DisablePrivateRemoteIPs,
|
|
"public_ip": publicIP,
|
|
"udp_port_min": relayConfig.UDPPortMin,
|
|
"udp_port_max": relayConfig.UDPPortMax,
|
|
"ice_server_count": len(relayConfig.ICEServers),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) currentRuntime() (*config.Config, mediaRelayFactory, error) {
|
|
if h == nil {
|
|
return nil, nil, nil
|
|
}
|
|
h.mediaRelayMu.RLock()
|
|
cfg := h.cfg
|
|
relay := h.mediaRelay
|
|
relayErr := h.mediaRelayErr
|
|
h.mediaRelayMu.RUnlock()
|
|
return cfg, relay, relayErr
|
|
}
|
|
|
|
func (h *Handler) currentConfig() *config.Config {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
h.mediaRelayMu.RLock()
|
|
cfg := h.cfg
|
|
h.mediaRelayMu.RUnlock()
|
|
return cfg
|
|
}
|
|
|
|
func (h *Handler) currentMediaRelay() (mediaRelayFactory, error) {
|
|
if h == nil {
|
|
return nil, nil
|
|
}
|
|
h.mediaRelayMu.RLock()
|
|
relay := h.mediaRelay
|
|
relayErr := h.mediaRelayErr
|
|
h.mediaRelayMu.RUnlock()
|
|
return relay, relayErr
|
|
}
|
|
|
|
// Close releases all active Codex live sessions.
|
|
func (h *Handler) Close() {
|
|
if h == nil {
|
|
return
|
|
}
|
|
if h.sessions != nil {
|
|
h.sessions.closeAll("server_stopped")
|
|
}
|
|
if h.clientSecrets != nil {
|
|
h.clientSecrets.close()
|
|
}
|
|
}
|
|
|
|
// Handle forwards a WebRTC SDP bootstrap request to the Codex realtime calls endpoint.
|
|
func (h *Handler) Handle(c *gin.Context) {
|
|
if h == nil || h.authManager == nil {
|
|
writeLiveError(c, http.StatusServiceUnavailable, "Codex auth manager unavailable")
|
|
return
|
|
}
|
|
|
|
body, errRead := readBody(c.Request.Body)
|
|
if errRead != nil {
|
|
status := clienterror.HTTPStatusFromErrorOr(errRead, http.StatusBadRequest)
|
|
if errors.Is(errRead, errBodyTooLarge) {
|
|
status = http.StatusRequestEntityTooLarge
|
|
}
|
|
writeLiveError(c, status, errRead.Error())
|
|
return
|
|
}
|
|
upstreamBody, upstreamContentType, model, errPayload := prepareCallRequest(body, c.GetHeader("Content-Type"))
|
|
if errPayload == nil {
|
|
upstreamBody, upstreamContentType, model, errPayload = applyClientSecretCallSession(upstreamBody, upstreamContentType, model, clientSecretSession(c))
|
|
}
|
|
if errPayload == nil {
|
|
upstreamBody, model, errPayload = rewriteCallRequestModel(upstreamBody, upstreamContentType, model)
|
|
}
|
|
if errPayload != nil {
|
|
writeLiveError(c, http.StatusBadRequest, errPayload.Error())
|
|
return
|
|
}
|
|
runtimeConfig, mediaRelay, mediaRelayErr := h.currentRuntime()
|
|
if mediaRelayErr != nil {
|
|
writeLiveError(c, http.StatusServiceUnavailable, mediaRelayErr.Error())
|
|
return
|
|
}
|
|
var mediaSession mediaRelaySession
|
|
mediaRetained := false
|
|
|
|
ctx := context.WithValue(c.Request.Context(), "gin", c)
|
|
selectionOpts := coreexecutor.Options{
|
|
Headers: liveSelectionHeaders(c),
|
|
OriginalRequest: body,
|
|
}
|
|
selection, selected, errSelect := h.selectOAuth(ctx, model, selectionOpts)
|
|
if errSelect != nil {
|
|
writeSelectionError(c, errSelect)
|
|
return
|
|
}
|
|
if selected == nil {
|
|
if selection != nil {
|
|
selection.End("missing_auth")
|
|
}
|
|
writeLiveError(c, http.StatusServiceUnavailable, "Codex auth unavailable")
|
|
return
|
|
}
|
|
|
|
if selection != nil {
|
|
attemptCtx, releaseAttempt, errAttempt := selection.AttemptContext(ctx)
|
|
if errAttempt != nil {
|
|
selection.End("attempt_bind_failed")
|
|
writeLiveError(c, http.StatusServiceUnavailable, errAttempt.Error())
|
|
return
|
|
}
|
|
ctx = attemptCtx
|
|
defer releaseAttempt()
|
|
}
|
|
selectedIndex := selected.EnsureIndex()
|
|
logging.SetGinCPATraceID(c, selectedIndex)
|
|
if selection != nil {
|
|
defer func() {
|
|
if selection.Active() && !selection.Retained() {
|
|
selection.End("request_closed")
|
|
}
|
|
}()
|
|
}
|
|
|
|
if mediaRelay != nil {
|
|
clientOffer, errSDP := callRequestSDP(upstreamBody, upstreamContentType)
|
|
if errSDP != nil {
|
|
writeLiveError(c, http.StatusBadRequest, errSDP.Error())
|
|
return
|
|
}
|
|
var upstreamOffer string
|
|
mediaSession, upstreamOffer, errSDP = mediaRelay.NewSession(ctx, clientOffer, mediaSessionRoute{
|
|
proxyURL: proxyURLForAuth(runtimeConfig, selected),
|
|
credential: mediaCredentialName(selected, selectedIndex),
|
|
authIndex: selectedIndex,
|
|
})
|
|
if errSDP != nil {
|
|
writeLiveError(c, clienterror.HTTPStatusFromErrorOr(errSDP, http.StatusBadGateway), errSDP.Error())
|
|
return
|
|
}
|
|
defer func() {
|
|
if !mediaRetained {
|
|
if errClose := mediaSession.CloseWithReason("request_not_retained"); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live media: close unretained session")
|
|
}
|
|
}
|
|
}()
|
|
upstreamBody, upstreamContentType, errSDP = replaceCallRequestSDP(upstreamBody, upstreamContentType, upstreamOffer)
|
|
if errSDP != nil {
|
|
writeLiveError(c, http.StatusBadRequest, errSDP.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
baseHeaders := protocolHeaders(c.Request.Header)
|
|
baseHeaders.Set("Content-Type", upstreamContentType)
|
|
performRequest := func(current *auth.Auth) (*http.Response, error) {
|
|
headers := baseHeaders.Clone()
|
|
setAccountHeader(headers, current)
|
|
req, errRequest := h.authManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamCallURL, upstreamBody, headers)
|
|
if errRequest != nil {
|
|
return nil, errRequest
|
|
}
|
|
authType, authValue := current.AccountInfo()
|
|
helps.RecordAPIRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
|
|
URL: upstreamCallURL,
|
|
Method: http.MethodPost,
|
|
Headers: headersForLogging(req.Header),
|
|
Body: upstreamBody,
|
|
Provider: "codex",
|
|
AuthID: current.ID,
|
|
AuthLabel: current.Label,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
return h.authManager.HttpRequest(ctx, current, req)
|
|
}
|
|
|
|
if errContext := ctx.Err(); errContext != nil {
|
|
if selection != nil {
|
|
selection.End("attempt_canceled")
|
|
}
|
|
writeLiveError(c, clienterror.HTTPStatusFromErrorOr(errContext, http.StatusRequestTimeout), errContext.Error())
|
|
return
|
|
}
|
|
resp, errRequest := performRequest(selected)
|
|
if errRequest != nil {
|
|
if selection != nil {
|
|
selection.End("request_failed")
|
|
}
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest)
|
|
writeLiveError(c, clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), errRequest.Error())
|
|
return
|
|
}
|
|
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", model)
|
|
helps.RecordAPIResponseMetadata(ctx, runtimeConfig, resp.StatusCode, callResponseHeaders(resp.Header))
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex live: close unauthorized response body error: %v", errClose)
|
|
}
|
|
refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
|
|
if errRefresh != nil {
|
|
selection.End("refresh_failed")
|
|
writeSelectionError(c, errRefresh)
|
|
return
|
|
}
|
|
if !didRefresh || refreshed == nil {
|
|
selection.End("refresh_unavailable")
|
|
writeLiveError(c, http.StatusUnauthorized, "Codex credential unauthorized")
|
|
return
|
|
}
|
|
selected = refreshed
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
resp, errRequest = performRequest(selected)
|
|
if errRequest != nil {
|
|
selection.End("retry_failed")
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest)
|
|
writeLiveError(c, clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), errRequest.Error())
|
|
return
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", model)
|
|
}
|
|
}
|
|
|
|
var closeResponseOnce sync.Once
|
|
var closeResponseErr error
|
|
closeResponseBody := func() error {
|
|
closeResponseOnce.Do(func() {
|
|
closeResponseErr = resp.Body.Close()
|
|
if closeResponseErr != nil {
|
|
log.Errorf("codex live: close response body error: %v", closeResponseErr)
|
|
}
|
|
})
|
|
return closeResponseErr
|
|
}
|
|
defer func() { _ = closeResponseBody() }()
|
|
if selection != nil {
|
|
if errBind := selection.Bind(closeResponseBody); errBind != nil {
|
|
selection.End("response_bind_failed")
|
|
writeLiveError(c, http.StatusServiceUnavailable, errBind.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
responseHeaders := callResponseHeaders(resp.Header)
|
|
helps.RecordAPIResponseMetadata(ctx, runtimeConfig, resp.StatusCode, responseHeaders)
|
|
responseBody, errResponse := readLimitedBody(resp.Body)
|
|
if errResponse != nil {
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errResponse)
|
|
message := "Failed to read Codex live response"
|
|
status := clienterror.HTTPStatusFromErrorOr(errResponse, http.StatusBadGateway)
|
|
if errors.Is(errResponse, errBodyTooLarge) {
|
|
message = "Codex live response body too large"
|
|
status = http.StatusBadGateway
|
|
}
|
|
writeLiveError(c, status, message)
|
|
return
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, runtimeConfig, responseBody)
|
|
responseBodyToWrite := responseBody
|
|
success := resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices
|
|
callID := ""
|
|
if success {
|
|
callID = callIDFromLocation(resp.Header.Get("Location"))
|
|
if callID == "" && mediaSession != nil {
|
|
writeLiveError(c, http.StatusBadGateway, "Codex live response is missing a valid call ID")
|
|
return
|
|
}
|
|
if mediaSession != nil {
|
|
mediaSession.SetCallID(callID)
|
|
}
|
|
if callID != "" && strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") {
|
|
responseHeaders.Set("Location", "/v1/realtime/calls/"+callID)
|
|
}
|
|
}
|
|
if success && mediaSession != nil {
|
|
upstreamAnswer, errSDP := callResponseSDP(responseBody, resp.Header.Get("Content-Type"))
|
|
if errSDP != nil {
|
|
writeLiveError(c, http.StatusBadGateway, errSDP.Error())
|
|
return
|
|
}
|
|
downstreamAnswer, errAnswer := mediaSession.AcceptUpstreamAnswer(ctx, upstreamAnswer)
|
|
if errAnswer != nil {
|
|
writeLiveError(c, clienterror.HTTPStatusFromErrorOr(errAnswer, http.StatusBadGateway), errAnswer.Error())
|
|
return
|
|
}
|
|
responseBodyToWrite = []byte(downstreamAnswer)
|
|
responseHeaders.Set("Content-Type", "application/sdp")
|
|
}
|
|
var storedSession liveSession
|
|
sessionStored := false
|
|
if success && h.sessions != nil {
|
|
if callID != "" {
|
|
session := liveSession{authID: selected.ID, model: model, media: mediaSession}
|
|
session.ownerPrincipal, session.ownerProvider = requestOwner(c)
|
|
if principal, ok := c.Get(ClientSecretPrincipalContextKey); ok {
|
|
session.clientSecretPrincipal, _ = principal.(string)
|
|
}
|
|
if selection != nil {
|
|
if mediaSession != nil {
|
|
if errBind := selection.Bind(func() error {
|
|
return mediaSession.CloseWithReason("home_selection_closed")
|
|
}); errBind != nil {
|
|
selection.End("media_bind_failed")
|
|
writeLiveError(c, http.StatusServiceUnavailable, errBind.Error())
|
|
return
|
|
}
|
|
}
|
|
if errBind := selection.Bind(func() error {
|
|
// End outside the resource closer to avoid waiting on the closer itself.
|
|
go selection.End("session_drained")
|
|
return nil
|
|
}); errBind != nil {
|
|
selection.End("session_drain_bind_failed")
|
|
writeLiveError(c, http.StatusServiceUnavailable, errBind.Error())
|
|
return
|
|
}
|
|
selection.Retain()
|
|
session.homeSelection = selection
|
|
}
|
|
storedSession = h.sessions.put(callID, session)
|
|
sessionStored = storedSession.callID != ""
|
|
if mediaSession != nil {
|
|
mediaSession.SetCloseHandler(func(reason string) {
|
|
h.sessions.complete(storedSession, reason)
|
|
})
|
|
mediaRetained = true
|
|
}
|
|
}
|
|
}
|
|
writeResponseHeaders(c.Writer.Header(), responseHeaders)
|
|
c.Status(resp.StatusCode)
|
|
if _, errWrite := c.Writer.Write(responseBodyToWrite); errWrite != nil {
|
|
if sessionStored {
|
|
h.sessions.complete(storedSession, "response_write_failed")
|
|
}
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errWrite)
|
|
log.WithError(errWrite).Warn("codex live: write response body failed")
|
|
}
|
|
}
|
|
|
|
func mediaCredentialName(selected *auth.Auth, authIndex string) string {
|
|
if selected == nil {
|
|
return strings.TrimSpace(authIndex)
|
|
}
|
|
if label := strings.TrimSpace(selected.Label); label != "" {
|
|
return label
|
|
}
|
|
if fileName := strings.TrimSpace(selected.FileName); fileName != "" {
|
|
if baseName := strings.TrimSpace(filepath.Base(fileName)); baseName != "" && baseName != "." {
|
|
return baseName
|
|
}
|
|
}
|
|
return strings.TrimSpace(authIndex)
|
|
}
|
|
|
|
func (h *Handler) selectOAuth(ctx context.Context, model string, opts coreexecutor.Options) (*auth.HomeDispatchSelection, *auth.Auth, error) {
|
|
var selection *auth.HomeDispatchSelection
|
|
var selected *auth.Auth
|
|
var errSelect error
|
|
if h.authManager.HomeEnabled() {
|
|
selection, errSelect = h.authManager.SelectHomeAuthByKind(ctx, "codex", model, auth.AuthKindOAuth, opts)
|
|
if selection != nil {
|
|
selected = selection.CloneAuth()
|
|
}
|
|
} else {
|
|
selected, errSelect = h.authManager.SelectAuthByKind(ctx, "codex", "", auth.AuthKindOAuth, opts)
|
|
}
|
|
if errSelect != nil && selection != nil {
|
|
selection.End("selection_failed")
|
|
}
|
|
return selection, selected, errSelect
|
|
}
|
|
|
|
var errBodyTooLarge = errors.New("Codex live request body too large")
|
|
|
|
func readBody(body io.Reader) ([]byte, error) {
|
|
payload, errRead := readLimitedBody(body)
|
|
if errRead != nil {
|
|
if errors.Is(errRead, errBodyTooLarge) {
|
|
return nil, errRead
|
|
}
|
|
return nil, fmt.Errorf("failed to read Codex live request: %w", errRead)
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func readLimitedBody(body io.Reader) ([]byte, error) {
|
|
if body == nil {
|
|
return nil, nil
|
|
}
|
|
payload, errRead := io.ReadAll(io.LimitReader(body, maxBodySize+1))
|
|
if errRead != nil {
|
|
return nil, errRead
|
|
}
|
|
if len(payload) > maxBodySize {
|
|
return nil, errBodyTooLarge
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func prepareCallRequest(body []byte, contentType string) ([]byte, string, string, error) {
|
|
mediaType, params, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType == nil && strings.EqualFold(mediaType, "multipart/form-data") {
|
|
return multipartCallRequest(body, strings.TrimSpace(params["boundary"]))
|
|
}
|
|
model := modelFromJSON(body)
|
|
if model == "" {
|
|
model = defaultLiveModel
|
|
}
|
|
if strings.TrimSpace(contentType) == "" {
|
|
contentType = "application/json"
|
|
}
|
|
return body, contentType, model, nil
|
|
}
|
|
|
|
func applyClientSecretCallSession(body []byte, contentType, model string, session json.RawMessage) ([]byte, string, string, error) {
|
|
if len(session) == 0 {
|
|
return body, contentType, model, nil
|
|
}
|
|
mediaType, _, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType == nil && (strings.EqualFold(mediaType, "application/sdp") || strings.EqualFold(mediaType, "text/plain")) {
|
|
encoded, errEncode := encodeCallRequest(string(body), session)
|
|
if errEncode != nil {
|
|
return nil, "", "", errEncode
|
|
}
|
|
return encoded, "application/json", modelFromJSON(session), nil
|
|
}
|
|
if errMediaType != nil || !strings.EqualFold(mediaType, "application/json") {
|
|
return nil, "", "", errors.New("Realtime client secrets require an SDP or JSON call request")
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return nil, "", "", fmt.Errorf("failed to decode Realtime call request: %w", errUnmarshal)
|
|
}
|
|
payload["session"] = append(json.RawMessage(nil), session...)
|
|
encoded, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return nil, "", "", fmt.Errorf("failed to encode Realtime call request: %w", errMarshal)
|
|
}
|
|
return encoded, "application/json", modelFromJSON(session), nil
|
|
}
|
|
|
|
func rewriteCallRequestModel(body []byte, contentType, model string) ([]byte, string, error) {
|
|
upstreamModel := codexRealtimeModel(model)
|
|
mediaType, _, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType != nil || !strings.EqualFold(mediaType, "application/json") || len(bytes.TrimSpace(body)) == 0 {
|
|
return body, upstreamModel, nil
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to decode Realtime call request: %w", errUnmarshal)
|
|
}
|
|
changed := false
|
|
if sessionJSON, ok := payload["session"]; ok && len(sessionJSON) > 0 {
|
|
var session map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(sessionJSON, &session); errUnmarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to decode Realtime session: %w", errUnmarshal)
|
|
}
|
|
encodedModel, errMarshal := json.Marshal(upstreamModel)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Realtime model: %w", errMarshal)
|
|
}
|
|
session["model"] = encodedModel
|
|
encodedSession, errMarshal := json.Marshal(session)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Realtime session: %w", errMarshal)
|
|
}
|
|
payload["session"] = encodedSession
|
|
changed = true
|
|
} else if _, ok := payload["model"]; ok {
|
|
encodedModel, errMarshal := json.Marshal(upstreamModel)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Realtime model: %w", errMarshal)
|
|
}
|
|
payload["model"] = encodedModel
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return body, upstreamModel, nil
|
|
}
|
|
encoded, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Realtime call request: %w", errMarshal)
|
|
}
|
|
return encoded, upstreamModel, nil
|
|
}
|
|
|
|
func multipartCallRequest(body []byte, boundary string) ([]byte, string, string, error) {
|
|
if boundary == "" {
|
|
return nil, "", "", errors.New("Codex live multipart boundary is missing")
|
|
}
|
|
|
|
reader := multipart.NewReader(bytes.NewReader(body), boundary)
|
|
var sdp *string
|
|
var session json.RawMessage
|
|
model := ""
|
|
for {
|
|
part, errPart := reader.NextPart()
|
|
if errors.Is(errPart, io.EOF) {
|
|
break
|
|
}
|
|
if errPart != nil {
|
|
return nil, "", "", fmt.Errorf("failed to parse Codex live multipart body: %w", errPart)
|
|
}
|
|
partBody, errRead := io.ReadAll(part)
|
|
errClose := part.Close()
|
|
if errRead != nil {
|
|
return nil, "", "", fmt.Errorf("failed to read Codex live multipart field: %w", errRead)
|
|
}
|
|
if errClose != nil {
|
|
return nil, "", "", fmt.Errorf("failed to close Codex live multipart field: %w", errClose)
|
|
}
|
|
|
|
switch part.FormName() {
|
|
case "sdp":
|
|
value := string(partBody)
|
|
sdp = &value
|
|
case "session":
|
|
if !json.Valid(partBody) {
|
|
return nil, "", "", errors.New("Codex live session field must contain valid JSON")
|
|
}
|
|
session = append(json.RawMessage(nil), partBody...)
|
|
model = modelFromJSON(partBody)
|
|
}
|
|
}
|
|
if sdp == nil {
|
|
return nil, "", "", errors.New("Codex live multipart body requires an sdp field")
|
|
}
|
|
if model == "" {
|
|
model = defaultLiveModel
|
|
}
|
|
|
|
encoded, errEncode := encodeCallRequest(*sdp, session)
|
|
if errEncode != nil {
|
|
return nil, "", "", errEncode
|
|
}
|
|
return encoded, "application/json", model, nil
|
|
}
|
|
|
|
func encodeCallRequest(sdp string, session json.RawMessage) ([]byte, error) {
|
|
payload := struct {
|
|
SDP string `json:"sdp"`
|
|
Session json.RawMessage `json:"session,omitempty"`
|
|
}{
|
|
SDP: sdp,
|
|
Session: session,
|
|
}
|
|
encoded, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return nil, fmt.Errorf("failed to encode Codex live request: %w", errMarshal)
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
func callRequestSDP(body []byte, contentType string) (string, error) {
|
|
mediaType, _, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType == nil && (strings.EqualFold(mediaType, "application/sdp") || strings.EqualFold(mediaType, "text/plain")) {
|
|
if strings.TrimSpace(string(body)) == "" {
|
|
return "", errors.New("Codex live call request requires an SDP offer")
|
|
}
|
|
return string(body), nil
|
|
}
|
|
if errMediaType != nil || !strings.EqualFold(mediaType, "application/json") {
|
|
return "", errors.New("Codex live media relay requires an SDP or JSON call request")
|
|
}
|
|
var payload struct {
|
|
SDP string `json:"sdp"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return "", fmt.Errorf("failed to decode Codex live call request: %w", errUnmarshal)
|
|
}
|
|
if strings.TrimSpace(payload.SDP) == "" {
|
|
return "", errors.New("Codex live call request requires an SDP offer")
|
|
}
|
|
return payload.SDP, nil
|
|
}
|
|
|
|
func replaceCallRequestSDP(body []byte, contentType, sdp string) ([]byte, string, error) {
|
|
mediaType, _, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType == nil && (strings.EqualFold(mediaType, "application/sdp") || strings.EqualFold(mediaType, "text/plain")) {
|
|
encoded, errEncode := encodeCallRequest(sdp, nil)
|
|
if errEncode != nil {
|
|
return nil, "", errEncode
|
|
}
|
|
return encoded, "application/json", nil
|
|
}
|
|
if errMediaType != nil || !strings.EqualFold(mediaType, "application/json") {
|
|
return nil, "", errors.New("Codex live media relay requires an SDP or JSON call request")
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to decode Codex live call request: %w", errUnmarshal)
|
|
}
|
|
encodedSDP, errMarshal := json.Marshal(sdp)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Codex live SDP offer: %w", errMarshal)
|
|
}
|
|
payload["sdp"] = encodedSDP
|
|
encoded, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return nil, "", fmt.Errorf("failed to encode Codex live call request: %w", errMarshal)
|
|
}
|
|
return encoded, "application/json", nil
|
|
}
|
|
|
|
func callResponseSDP(body []byte, contentType string) (string, error) {
|
|
mediaType, _, errMediaType := mime.ParseMediaType(contentType)
|
|
if errMediaType == nil && strings.EqualFold(mediaType, "application/json") {
|
|
var payload struct {
|
|
SDP string `json:"sdp"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return "", fmt.Errorf("failed to decode Codex live response: %w", errUnmarshal)
|
|
}
|
|
if strings.TrimSpace(payload.SDP) == "" {
|
|
return "", errors.New("Codex live response requires an SDP answer")
|
|
}
|
|
return payload.SDP, nil
|
|
}
|
|
if strings.TrimSpace(string(body)) == "" {
|
|
return "", errors.New("Codex live response requires an SDP answer")
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
func modelFromJSON(body []byte) string {
|
|
var payload struct {
|
|
Model string `json:"model"`
|
|
Session struct {
|
|
Model string `json:"model"`
|
|
} `json:"session"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
|
return ""
|
|
}
|
|
if model := strings.TrimSpace(payload.Session.Model); model != "" {
|
|
return model
|
|
}
|
|
return strings.TrimSpace(payload.Model)
|
|
}
|
|
|
|
func protocolHeaders(source http.Header) http.Header {
|
|
headers := make(http.Header)
|
|
for _, name := range liveProtocolHeaders {
|
|
for _, value := range source.Values(name) {
|
|
headers.Add(name, value)
|
|
}
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func setAccountHeader(headers http.Header, selected *auth.Auth) {
|
|
if selected == nil {
|
|
return
|
|
}
|
|
if accountID, ok := selected.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
|
|
headers.Set("Chatgpt-Account-Id", accountID)
|
|
}
|
|
}
|
|
|
|
func headersForLogging(source http.Header) http.Header {
|
|
headers := source.Clone()
|
|
if headers.Get("X-Oai-Attestation") != "" {
|
|
headers.Set("X-Oai-Attestation", "[REDACTED]")
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func callResponseHeaders(source http.Header) http.Header {
|
|
headers := make(http.Header)
|
|
for _, name := range []string{"Content-Type", "Location", "Retry-After", "X-Request-Id", "OpenAI-Request-Id"} {
|
|
for _, value := range source.Values(name) {
|
|
headers.Add(name, value)
|
|
}
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func writeResponseHeaders(destination, source http.Header) {
|
|
for name, values := range source {
|
|
for _, value := range values {
|
|
destination.Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeLiveError(c *gin.Context, status int, message string) {
|
|
if c != nil && c.Request != nil && c.Request.URL != nil && strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") {
|
|
errorType := "api_error"
|
|
if status >= http.StatusBadRequest && status < http.StatusInternalServerError {
|
|
errorType = "invalid_request_error"
|
|
}
|
|
if status == http.StatusUnauthorized {
|
|
errorType = "authentication_error"
|
|
}
|
|
writeRealtimeError(c, status, message, errorType, "realtime_request_failed")
|
|
return
|
|
}
|
|
c.JSON(status, gin.H{"error": message})
|
|
}
|
|
|
|
func writeSelectionError(c *gin.Context, err error) {
|
|
status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable)
|
|
for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") {
|
|
c.Writer.Header().Add("Retry-After", value)
|
|
}
|
|
writeLiveError(c, status, err.Error())
|
|
}
|