215 lines
8.4 KiB
Go
215 lines
8.4 KiB
Go
package live
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"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"
|
|
)
|
|
|
|
// HandleTranslation reports that the Codex OAuth upstream has no translation session capability.
|
|
func (h *Handler) HandleTranslation(c *gin.Context) {
|
|
writeCapabilityNotSupported(c, "Realtime translation sessions")
|
|
}
|
|
|
|
// HandleTranscriptionSession reports that the Codex OAuth upstream has no transcription-only capability.
|
|
func (h *Handler) HandleTranscriptionSession(c *gin.Context) {
|
|
writeCapabilityNotSupported(c, "Realtime transcription-only sessions")
|
|
}
|
|
|
|
// HandleSIPControl reports that the Codex OAuth upstream has no SIP dialog capability.
|
|
func (h *Handler) HandleSIPControl(c *gin.Context) {
|
|
action := "control"
|
|
if c != nil && c.Request != nil && c.Request.URL != nil {
|
|
parts := strings.Split(strings.Trim(c.Request.URL.Path, "/"), "/")
|
|
if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) != "" {
|
|
action = parts[len(parts)-1]
|
|
}
|
|
}
|
|
writeCapabilityNotSupported(c, "Realtime SIP "+action)
|
|
}
|
|
|
|
// HandleHangup forwards hangup for a locally created WebRTC call using its pinned OAuth credential.
|
|
func (h *Handler) HandleHangup(c *gin.Context) {
|
|
if h == nil || h.authManager == nil || h.sessions == nil {
|
|
writeRealtimeError(c, http.StatusServiceUnavailable, "Codex live session service unavailable", "server_error", "realtime_session_unavailable")
|
|
return
|
|
}
|
|
callID := strings.TrimSpace(c.Param("call_id"))
|
|
if !callIDPattern.MatchString(callID) {
|
|
writeRealtimeError(c, http.StatusBadRequest, "Invalid Realtime call ID", "invalid_request_error", "invalid_call_id")
|
|
return
|
|
}
|
|
session, ok := h.sessions.peek(callID)
|
|
if !ok {
|
|
writeRealtimeError(c, http.StatusNotFound, "Realtime call not found", "invalid_request_error", "realtime_call_not_found")
|
|
return
|
|
}
|
|
|
|
if ownerPrincipal, ownerProvider := requestOwner(c); session.ownerPrincipal != "" && (ownerPrincipal != session.ownerPrincipal || ownerProvider != session.ownerProvider) {
|
|
writeRealtimeError(c, http.StatusForbidden, "Realtime call belongs to another API principal", "invalid_request_error", "realtime_call_scope_mismatch")
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(c.Request.Context(), "gin", c)
|
|
var activeSelection *auth.HomeDispatchSelection
|
|
var temporarySelection bool
|
|
var selected *auth.Auth
|
|
if session.homeSelection != nil && session.homeSelection.Active() {
|
|
activeSelection = session.homeSelection
|
|
selected = activeSelection.CloneAuth()
|
|
} else {
|
|
selectionOpts := coreexecutor.Options{
|
|
Headers: liveSelectionHeaders(c),
|
|
Metadata: map[string]any{
|
|
coreexecutor.PinnedAuthMetadataKey: session.authID,
|
|
coreexecutor.ExecutionSessionMetadataKey: callID,
|
|
},
|
|
}
|
|
selection, selectedAuth, errSelect := h.selectOAuth(ctx, session.model, selectionOpts)
|
|
if errSelect != nil {
|
|
writeSelectionError(c, errSelect)
|
|
return
|
|
}
|
|
activeSelection = selection
|
|
selected = selectedAuth
|
|
temporarySelection = selection != nil
|
|
}
|
|
var selectionRelease func()
|
|
if activeSelection != nil {
|
|
attemptCtx, releaseAttempt, errAttempt := activeSelection.AttemptContext(ctx)
|
|
if errAttempt != nil {
|
|
if temporarySelection {
|
|
activeSelection.End("attempt_bind_failed")
|
|
}
|
|
writeRealtimeError(c, http.StatusServiceUnavailable, errAttempt.Error(), "server_error", "realtime_upstream_unavailable")
|
|
return
|
|
}
|
|
ctx = attemptCtx
|
|
selectionRelease = releaseAttempt
|
|
}
|
|
defer func() {
|
|
if selectionRelease != nil {
|
|
selectionRelease()
|
|
}
|
|
if temporarySelection && activeSelection != nil {
|
|
activeSelection.End("request_closed")
|
|
}
|
|
}()
|
|
if selected == nil {
|
|
writeRealtimeError(c, http.StatusServiceUnavailable, "Codex auth unavailable", "server_error", "codex_auth_unavailable")
|
|
return
|
|
}
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
|
|
body, errRead := readBody(c.Request.Body)
|
|
if errRead != nil {
|
|
writeRealtimeError(c, http.StatusBadRequest, errRead.Error(), "invalid_request_error", "invalid_request")
|
|
return
|
|
}
|
|
upstreamURL := h.realtimeHTTPBaseURL() + "/realtime/calls/" + url.PathEscape(callID) + "/hangup"
|
|
baseHeaders := protocolHeaders(c.Request.Header)
|
|
if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" {
|
|
baseHeaders.Set("Content-Type", contentType)
|
|
}
|
|
runtimeConfig := h.currentConfig()
|
|
performRequest := func(current *auth.Auth) (*http.Response, error) {
|
|
headers := baseHeaders.Clone()
|
|
setAccountHeader(headers, current)
|
|
request, errRequest := h.authManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamURL, body, headers)
|
|
if errRequest != nil {
|
|
return nil, errRequest
|
|
}
|
|
authType, authValue := current.AccountInfo()
|
|
helps.RecordAPIRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
|
|
URL: upstreamURL,
|
|
Method: http.MethodPost,
|
|
Headers: headersForLogging(request.Header),
|
|
Body: body,
|
|
Provider: "codex",
|
|
AuthID: current.ID,
|
|
AuthLabel: current.Label,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
return h.authManager.HttpRequest(ctx, current, request)
|
|
}
|
|
response, errRequest := performRequest(selected)
|
|
if errRequest != nil {
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest)
|
|
writeRealtimeError(c, clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), errRequest.Error(), "api_error", "realtime_upstream_unavailable")
|
|
return
|
|
}
|
|
if activeSelection != nil && response.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
|
if errClose := response.Body.Close(); errClose != nil {
|
|
log.Errorf("codex realtime hangup: close unauthorized response body error: %v", errClose)
|
|
}
|
|
refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, activeSelection, selected)
|
|
if errRefresh != nil {
|
|
writeSelectionError(c, errRefresh)
|
|
return
|
|
}
|
|
if !didRefresh || refreshed == nil {
|
|
writeRealtimeError(c, http.StatusUnauthorized, "Codex credential unauthorized", "authentication_error", "realtime_upstream_unauthorized")
|
|
return
|
|
}
|
|
selected = refreshed
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
response, errRequest = performRequest(selected)
|
|
if errRequest != nil {
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest)
|
|
writeRealtimeError(c, clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), errRequest.Error(), "api_error", "realtime_upstream_unavailable")
|
|
return
|
|
}
|
|
if response.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
|
}
|
|
}
|
|
defer func() {
|
|
if errClose := response.Body.Close(); errClose != nil {
|
|
log.Errorf("codex realtime hangup: close response body error: %v", errClose)
|
|
}
|
|
}()
|
|
responseBody, errResponse := readLimitedBody(response.Body)
|
|
if errResponse != nil {
|
|
helps.RecordAPIResponseError(ctx, runtimeConfig, errResponse)
|
|
writeRealtimeError(c, http.StatusBadGateway, "Failed to read Realtime hangup response", "api_error", "realtime_upstream_unavailable")
|
|
return
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, runtimeConfig, response.StatusCode, callResponseHeaders(response.Header))
|
|
helps.AppendAPIResponseChunk(ctx, runtimeConfig, responseBody)
|
|
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
|
if selectionRelease != nil {
|
|
selectionRelease()
|
|
selectionRelease = nil
|
|
}
|
|
h.sessions.complete(session, "client_hangup")
|
|
}
|
|
if contentType := response.Header.Get("Content-Type"); contentType != "" {
|
|
c.Header("Content-Type", contentType)
|
|
}
|
|
copyRealtimeHandshakeHeaders(c.Writer.Header(), response.Header)
|
|
c.Status(response.StatusCode)
|
|
if _, errWrite := c.Writer.Write(responseBody); errWrite != nil {
|
|
log.WithError(errWrite).Warn("codex realtime hangup: write response body failed")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) realtimeHTTPBaseURL() string {
|
|
return strings.TrimRight(websocketHTTPURL(h.sidebandAPIBaseURL), "/")
|
|
}
|
|
|
|
func writeCapabilityNotSupported(c *gin.Context, capability string) {
|
|
writeRealtimeError(c, http.StatusNotImplemented, capability+" are not supported by the ChatGPT/Codex OAuth upstream", "not_supported_error", "realtime_capability_not_supported")
|
|
}
|