Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
215
backend/internal/client/codex/live/capabilities.go
Normal file
215
backend/internal/client/codex/live/capabilities.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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")
|
||||
}
|
||||
97
backend/internal/client/codex/live/capabilities_test.go
Normal file
97
backend/internal/client/codex/live/capabilities_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestHandleHangupForwardsPinnedOAuthCall(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
manager := auth.NewManager(nil, nil, nil)
|
||||
executor := &captureExecutor{
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(`{"status":"ok"}`)),
|
||||
}
|
||||
manager.RegisterExecutor(executor)
|
||||
registerCredential(t, manager, &auth.Auth{
|
||||
ID: "codex-oauth",
|
||||
Provider: "codex",
|
||||
Status: auth.StatusActive,
|
||||
Metadata: map[string]any{"access_token": "oauth-token"},
|
||||
})
|
||||
handler := NewHandler(manager, nil)
|
||||
handler.sessions.put("call-123", liveSession{
|
||||
authID: "codex-oauth",
|
||||
model: defaultLiveModel,
|
||||
ownerPrincipal: "owner-key",
|
||||
ownerProvider: "static",
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/calls/:call_id/hangup", func(c *gin.Context) {
|
||||
c.Set("userApiKey", "owner-key")
|
||||
c.Set("accessProvider", "static")
|
||||
c.Next()
|
||||
}, handler.HandleHangup)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/realtime/calls/call-123/hangup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if executor.request == nil || executor.request.URL.String() != "https://api.openai.com/v1/realtime/calls/call-123/hangup" {
|
||||
t.Fatalf("upstream request = %#v", executor.request)
|
||||
}
|
||||
if _, ok := handler.sessions.peek("call-123"); ok {
|
||||
t.Fatal("successful hangup retained session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHangupRejectsDifferentAPIPrincipal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(auth.NewManager(nil, nil, nil), nil)
|
||||
handler.sessions.put("call-123", liveSession{
|
||||
authID: "codex-oauth",
|
||||
model: defaultLiveModel,
|
||||
ownerPrincipal: "owner-key",
|
||||
ownerProvider: "static",
|
||||
})
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/calls/:call_id/hangup", func(c *gin.Context) {
|
||||
c.Set("userApiKey", "other-key")
|
||||
c.Set("accessProvider", "static")
|
||||
c.Next()
|
||||
}, handler.HandleHangup)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/realtime/calls/call-123/hangup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusForbidden, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedRealtimeCapabilitiesUseStandardError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(nil, nil)
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/transcription_sessions", handler.HandleTranscriptionSession)
|
||||
router.POST("/v1/realtime/calls/:call_id/accept", handler.HandleSIPControl)
|
||||
|
||||
for _, path := range []string{"/v1/realtime/transcription_sessions", "/v1/realtime/calls/call-123/accept"} {
|
||||
request := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNotImplemented {
|
||||
t.Errorf("%s status = %d, want %d", path, recorder.Code, http.StatusNotImplemented)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"type":"not_supported_error"`) || !strings.Contains(recorder.Body.String(), `"code":"realtime_capability_not_supported"`) {
|
||||
t.Errorf("%s body = %s", path, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
419
backend/internal/client/codex/live/client_secret.go
Normal file
419
backend/internal/client/codex/live/client_secret.go
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientSecretSessionContextKey = "codexLiveClientSecretSession"
|
||||
ClientSecretPrincipalContextKey = "codexLiveClientSecretPrincipal"
|
||||
clientSecretPrefix = "ek_"
|
||||
clientSecretDefaultLifetime = 10 * time.Minute
|
||||
clientSecretMinimumLifetime = 10 * time.Second
|
||||
clientSecretMaximumLifetime = 2 * time.Hour
|
||||
clientSecretMaxBodySize = 64 << 10
|
||||
clientSecretMaxEntries = 1024
|
||||
clientSecretMaxEntriesPerIssuer = 64
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidClientSecret = errors.New("Realtime client secret is invalid or expired")
|
||||
errClientSecretCapacity = errors.New("Realtime client secret capacity exhausted")
|
||||
errUnsupportedSessionType = errors.New("Realtime session type is not supported")
|
||||
)
|
||||
|
||||
// ClientSecretAuthorization contains the local session configuration associated with an ephemeral key.
|
||||
type ClientSecretAuthorization struct {
|
||||
Principal string
|
||||
IssuerPrincipal string
|
||||
IssuerProvider string
|
||||
Session json.RawMessage
|
||||
}
|
||||
|
||||
type clientSecretEntry struct {
|
||||
authorization ClientSecretAuthorization
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type clientSecretStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]clientSecretEntry
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type clientSecretCreateRequest struct {
|
||||
Session json.RawMessage `json:"session"`
|
||||
ExpiresAfter *struct {
|
||||
Anchor string `json:"anchor"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
} `json:"expires_after,omitempty"`
|
||||
}
|
||||
|
||||
type clientSecretCreateResponse struct {
|
||||
Value string `json:"value"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Session json.RawMessage `json:"session"`
|
||||
}
|
||||
|
||||
func newClientSecretStore() *clientSecretStore {
|
||||
return &clientSecretStore{
|
||||
entries: make(map[string]clientSecretEntry),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *clientSecretStore) create(session json.RawMessage, lifetime time.Duration, issuerPrincipal, issuerProvider string) (string, ClientSecretAuthorization, time.Time, error) {
|
||||
if s == nil {
|
||||
return "", ClientSecretAuthorization{}, time.Time{}, errors.New("Realtime client secret store unavailable")
|
||||
}
|
||||
token, errToken := randomRealtimeID(clientSecretPrefix, 32)
|
||||
if errToken != nil {
|
||||
return "", ClientSecretAuthorization{}, time.Time{}, errToken
|
||||
}
|
||||
sessionID, errSessionID := randomRealtimeID("sess_", 18)
|
||||
if errSessionID != nil {
|
||||
return "", ClientSecretAuthorization{}, time.Time{}, errSessionID
|
||||
}
|
||||
authorization := ClientSecretAuthorization{
|
||||
Principal: sessionID,
|
||||
IssuerPrincipal: strings.TrimSpace(issuerPrincipal),
|
||||
IssuerProvider: strings.TrimSpace(issuerProvider),
|
||||
Session: append(json.RawMessage(nil), session...),
|
||||
}
|
||||
now := s.currentTime()
|
||||
expiresAt := now.Add(lifetime)
|
||||
s.mu.Lock()
|
||||
s.removeExpiredLocked(now)
|
||||
if len(s.entries) >= clientSecretMaxEntries {
|
||||
s.mu.Unlock()
|
||||
return "", ClientSecretAuthorization{}, time.Time{}, errClientSecretCapacity
|
||||
}
|
||||
if authorization.IssuerPrincipal != "" {
|
||||
issuerEntries := 0
|
||||
for _, entry := range s.entries {
|
||||
if entry.authorization.IssuerPrincipal == authorization.IssuerPrincipal && entry.authorization.IssuerProvider == authorization.IssuerProvider {
|
||||
issuerEntries++
|
||||
}
|
||||
}
|
||||
if issuerEntries >= clientSecretMaxEntriesPerIssuer {
|
||||
s.mu.Unlock()
|
||||
return "", ClientSecretAuthorization{}, time.Time{}, errClientSecretCapacity
|
||||
}
|
||||
}
|
||||
s.entries[token] = clientSecretEntry{authorization: authorization, expiresAt: expiresAt}
|
||||
s.mu.Unlock()
|
||||
return token, authorization, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *clientSecretStore) authenticate(token string) (ClientSecretAuthorization, error) {
|
||||
if s == nil || !strings.HasPrefix(token, clientSecretPrefix) {
|
||||
return ClientSecretAuthorization{}, errInvalidClientSecret
|
||||
}
|
||||
now := s.currentTime()
|
||||
s.mu.Lock()
|
||||
entry, ok := s.entries[token]
|
||||
if !ok || !entry.expiresAt.After(now) {
|
||||
delete(s.entries, token)
|
||||
s.mu.Unlock()
|
||||
return ClientSecretAuthorization{}, errInvalidClientSecret
|
||||
}
|
||||
s.mu.Unlock()
|
||||
entry.authorization.Session = append(json.RawMessage(nil), entry.authorization.Session...)
|
||||
return entry.authorization, nil
|
||||
}
|
||||
|
||||
func (s *clientSecretStore) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
clear(s.entries)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *clientSecretStore) currentTime() time.Time {
|
||||
if s != nil && s.now != nil {
|
||||
return s.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (s *clientSecretStore) removeExpiredLocked(now time.Time) {
|
||||
for token, entry := range s.entries {
|
||||
if !entry.expiresAt.After(now) {
|
||||
delete(s.entries, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readClientSecretBody(body io.Reader) ([]byte, error) {
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
payload, errRead := io.ReadAll(io.LimitReader(body, clientSecretMaxBodySize+1))
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("failed to read Realtime client secret request: %w", errRead)
|
||||
}
|
||||
if len(payload) > clientSecretMaxBodySize {
|
||||
return nil, errBodyTooLarge
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func randomRealtimeID(prefix string, size int) (string, error) {
|
||||
payload := make([]byte, size)
|
||||
if _, errRead := rand.Read(payload); errRead != nil {
|
||||
return "", fmt.Errorf("generate Realtime identifier: %w", errRead)
|
||||
}
|
||||
return prefix + base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
// AuthenticateClientSecret validates a local ephemeral key when the request carries one.
|
||||
func (h *Handler) AuthenticateClientSecret(request *http.Request) (ClientSecretAuthorization, bool, error) {
|
||||
token := bearerToken(request)
|
||||
if !strings.HasPrefix(token, clientSecretPrefix) {
|
||||
return ClientSecretAuthorization{}, false, nil
|
||||
}
|
||||
if h == nil || h.clientSecrets == nil {
|
||||
return ClientSecretAuthorization{}, true, errInvalidClientSecret
|
||||
}
|
||||
authorization, errAuthenticate := h.clientSecrets.authenticate(token)
|
||||
return authorization, true, errAuthenticate
|
||||
}
|
||||
|
||||
func bearerToken(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
authorization := strings.TrimSpace(request.Header.Get("Authorization"))
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(authorization) < len(bearerPrefix) || !strings.EqualFold(authorization[:len(bearerPrefix)], bearerPrefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(authorization[len(bearerPrefix):])
|
||||
}
|
||||
|
||||
// CreateClientSecret creates a short-lived credential scoped to this proxy.
|
||||
func (h *Handler) CreateClientSecret(c *gin.Context) {
|
||||
if h == nil || h.clientSecrets == nil {
|
||||
writeRealtimeError(c, http.StatusServiceUnavailable, "Realtime client secret service unavailable", "server_error", "realtime_client_secret_unavailable")
|
||||
return
|
||||
}
|
||||
body, errRead := readClientSecretBody(c.Request.Body)
|
||||
if errRead != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(errRead, errBodyTooLarge) {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
writeRealtimeError(c, status, errRead.Error(), "invalid_request_error", "invalid_request")
|
||||
return
|
||||
}
|
||||
var request clientSecretCreateRequest
|
||||
if len(strings.TrimSpace(string(body))) > 0 {
|
||||
if errUnmarshal := json.Unmarshal(body, &request); errUnmarshal != nil {
|
||||
writeRealtimeError(c, http.StatusBadRequest, "Invalid Realtime client secret request", "invalid_request_error", "invalid_request")
|
||||
return
|
||||
}
|
||||
}
|
||||
h.createClientSecret(c, request.Session, request.ExpiresAfter, false)
|
||||
}
|
||||
|
||||
// CreateLegacySession implements the deprecated Realtime session credential endpoint.
|
||||
func (h *Handler) CreateLegacySession(c *gin.Context) {
|
||||
if h == nil || h.clientSecrets == nil {
|
||||
writeRealtimeError(c, http.StatusServiceUnavailable, "Realtime client secret service unavailable", "server_error", "realtime_client_secret_unavailable")
|
||||
return
|
||||
}
|
||||
body, errRead := readClientSecretBody(c.Request.Body)
|
||||
if errRead != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(errRead, errBodyTooLarge) {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
writeRealtimeError(c, status, errRead.Error(), "invalid_request_error", "invalid_request")
|
||||
return
|
||||
}
|
||||
h.createClientSecret(c, json.RawMessage(body), nil, true)
|
||||
}
|
||||
|
||||
func (h *Handler) createClientSecret(c *gin.Context, session json.RawMessage, expiresAfter *struct {
|
||||
Anchor string `json:"anchor"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
}, legacy bool) {
|
||||
lifetime, errLifetime := clientSecretLifetime(expiresAfter)
|
||||
if errLifetime != nil {
|
||||
writeRealtimeError(c, http.StatusBadRequest, errLifetime.Error(), "invalid_request_error", "invalid_expires_after")
|
||||
return
|
||||
}
|
||||
clientSession, upstreamSession, errSession := normalizeClientSecretSession(session)
|
||||
if errSession != nil {
|
||||
if errors.Is(errSession, errUnsupportedSessionType) {
|
||||
writeRealtimeError(c, http.StatusNotImplemented, errSession.Error(), "not_supported_error", "realtime_capability_not_supported")
|
||||
return
|
||||
}
|
||||
writeRealtimeError(c, http.StatusBadRequest, errSession.Error(), "invalid_request_error", "invalid_session")
|
||||
return
|
||||
}
|
||||
issuerPrincipal, _ := c.Get("userApiKey")
|
||||
issuerProvider, _ := c.Get("accessProvider")
|
||||
issuerPrincipalValue, _ := issuerPrincipal.(string)
|
||||
issuerProviderValue, _ := issuerProvider.(string)
|
||||
token, authorization, expiresAt, errCreate := h.clientSecrets.create(upstreamSession, lifetime, issuerPrincipalValue, issuerProviderValue)
|
||||
if errCreate != nil {
|
||||
if errors.Is(errCreate, errClientSecretCapacity) {
|
||||
c.Header("Retry-After", "1")
|
||||
writeRealtimeError(c, http.StatusTooManyRequests, errCreate.Error(), "rate_limit_error", "realtime_client_secret_capacity_exhausted")
|
||||
return
|
||||
}
|
||||
writeRealtimeError(c, http.StatusInternalServerError, "Failed to create Realtime client secret", "server_error", "realtime_client_secret_failed")
|
||||
return
|
||||
}
|
||||
responseSession, errResponse := realtimeSessionResponse(clientSession, authorization.Principal, expiresAt)
|
||||
if errResponse != nil {
|
||||
writeRealtimeError(c, http.StatusInternalServerError, "Failed to encode Realtime session", "server_error", "realtime_session_failed")
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
if legacy {
|
||||
var response map[string]any
|
||||
if errUnmarshal := json.Unmarshal(responseSession, &response); errUnmarshal != nil {
|
||||
writeRealtimeError(c, http.StatusInternalServerError, "Failed to encode Realtime session", "server_error", "realtime_session_failed")
|
||||
return
|
||||
}
|
||||
response["client_secret"] = gin.H{"value": token, "expires_at": expiresAt.Unix()}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, clientSecretCreateResponse{
|
||||
Value: token,
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
Session: responseSession,
|
||||
})
|
||||
}
|
||||
|
||||
func clientSecretLifetime(expiresAfter *struct {
|
||||
Anchor string `json:"anchor"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
}) (time.Duration, error) {
|
||||
if expiresAfter == nil {
|
||||
return clientSecretDefaultLifetime, nil
|
||||
}
|
||||
if expiresAfter.Anchor != "" && expiresAfter.Anchor != "created_at" {
|
||||
return 0, errors.New("expires_after.anchor must be created_at")
|
||||
}
|
||||
minimumSeconds := int64(clientSecretMinimumLifetime / time.Second)
|
||||
maximumSeconds := int64(clientSecretMaximumLifetime / time.Second)
|
||||
if expiresAfter.Seconds < minimumSeconds || expiresAfter.Seconds > maximumSeconds {
|
||||
return 0, fmt.Errorf("expires_after.seconds must be between %d and %d", minimumSeconds, maximumSeconds)
|
||||
}
|
||||
return time.Duration(expiresAfter.Seconds) * time.Second, nil
|
||||
}
|
||||
|
||||
func normalizeClientSecretSession(session json.RawMessage) (json.RawMessage, json.RawMessage, error) {
|
||||
trimmedSession := strings.TrimSpace(string(session))
|
||||
if trimmedSession == "" || trimmedSession == "null" {
|
||||
session = json.RawMessage(`{"type":"realtime","model":"gpt-realtime"}`)
|
||||
}
|
||||
var clientSession map[string]any
|
||||
if errUnmarshal := json.Unmarshal(session, &clientSession); errUnmarshal != nil || clientSession == nil {
|
||||
return nil, nil, errors.New("session must be a valid JSON object")
|
||||
}
|
||||
sessionType, _ := clientSession["type"].(string)
|
||||
if strings.TrimSpace(sessionType) == "" {
|
||||
sessionType = "realtime"
|
||||
clientSession["type"] = sessionType
|
||||
}
|
||||
if sessionType != "realtime" {
|
||||
return nil, nil, fmt.Errorf("%w by the Codex OAuth upstream: %q", errUnsupportedSessionType, sessionType)
|
||||
}
|
||||
model, _ := clientSession["model"].(string)
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "gpt-realtime"
|
||||
clientSession["model"] = model
|
||||
}
|
||||
clientEncoded, errMarshal := json.Marshal(clientSession)
|
||||
if errMarshal != nil {
|
||||
return nil, nil, fmt.Errorf("encode Realtime session: %w", errMarshal)
|
||||
}
|
||||
clientSession["model"] = codexRealtimeModel(model)
|
||||
upstreamEncoded, errMarshal := json.Marshal(clientSession)
|
||||
if errMarshal != nil {
|
||||
return nil, nil, fmt.Errorf("encode Codex Realtime session: %w", errMarshal)
|
||||
}
|
||||
return clientEncoded, upstreamEncoded, nil
|
||||
}
|
||||
|
||||
func realtimeSessionResponse(session json.RawMessage, sessionID string, expiresAt time.Time) (json.RawMessage, error) {
|
||||
var response map[string]any
|
||||
if errUnmarshal := json.Unmarshal(session, &response); errUnmarshal != nil {
|
||||
return nil, errUnmarshal
|
||||
}
|
||||
response["id"] = sessionID
|
||||
response["object"] = "realtime.session"
|
||||
response["expires_at"] = expiresAt.Unix()
|
||||
return json.Marshal(response)
|
||||
}
|
||||
|
||||
func codexRealtimeModel(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if lower == "" || lower == "gpt-realtime" || strings.HasPrefix(lower, "gpt-realtime-") || strings.Contains(lower, "realtime-preview") {
|
||||
return defaultLiveModel
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func liveSelectionHeaders(c *gin.Context) http.Header {
|
||||
if c == nil || c.Request == nil {
|
||||
return make(http.Header)
|
||||
}
|
||||
headers := c.Request.Header.Clone()
|
||||
if _, ok := c.Get(ClientSecretPrincipalContextKey); ok {
|
||||
headers.Del("Authorization")
|
||||
headers.Del("Proxy-Authorization")
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func requestOwner(c *gin.Context) (string, string) {
|
||||
if c == nil {
|
||||
return "", ""
|
||||
}
|
||||
principalValue, _ := c.Get("userApiKey")
|
||||
providerValue, _ := c.Get("accessProvider")
|
||||
principal, _ := principalValue.(string)
|
||||
provider, _ := providerValue.(string)
|
||||
return strings.TrimSpace(principal), strings.TrimSpace(provider)
|
||||
}
|
||||
|
||||
func clientSecretSession(c *gin.Context) json.RawMessage {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := c.Get(ClientSecretSessionContextKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
session, _ := value.(json.RawMessage)
|
||||
return append(json.RawMessage(nil), session...)
|
||||
}
|
||||
|
||||
func writeRealtimeError(c *gin.Context, status int, message, errorType, code string) {
|
||||
c.JSON(status, gin.H{"error": gin.H{
|
||||
"message": message,
|
||||
"type": errorType,
|
||||
"param": nil,
|
||||
"code": code,
|
||||
}})
|
||||
}
|
||||
262
backend/internal/client/codex/live/client_secret_test.go
Normal file
262
backend/internal/client/codex/live/client_secret_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestCreateClientSecretMapsStandardRealtimeModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &Handler{clientSecrets: newClientSecretStore()}
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/client_secrets", func(c *gin.Context) {
|
||||
c.Set("userApiKey", "issuer-key")
|
||||
c.Set("accessProvider", "static")
|
||||
c.Next()
|
||||
}, handler.CreateClientSecret)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/realtime/client_secrets", strings.NewReader(`{
|
||||
"session":{"type":"realtime","model":"gpt-realtime","instructions":"help"},
|
||||
"expires_after":{"anchor":"created_at","seconds":60}
|
||||
}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Value string `json:"value"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal response: %v", errUnmarshal)
|
||||
}
|
||||
if !strings.HasPrefix(response.Value, clientSecretPrefix) {
|
||||
t.Fatalf("client secret = %q", response.Value)
|
||||
}
|
||||
if response.ExpiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("expires_at = %d", response.ExpiresAt)
|
||||
}
|
||||
if response.Session.ID == "" || response.Session.Object != "realtime.session" || response.Session.Type != "realtime" {
|
||||
t.Fatalf("session = %+v", response.Session)
|
||||
}
|
||||
if response.Session.Model != "gpt-realtime" || response.Session.Instructions != "help" {
|
||||
t.Fatalf("client session = %+v", response.Session)
|
||||
}
|
||||
|
||||
authRequest := httptest.NewRequest(http.MethodPost, "/v1/realtime/calls", nil)
|
||||
authRequest.Header.Set("Authorization", "Bearer "+response.Value)
|
||||
authorization, matched, errAuthenticate := handler.AuthenticateClientSecret(authRequest)
|
||||
if errAuthenticate != nil || !matched {
|
||||
t.Fatalf("AuthenticateClientSecret() matched=%t error=%v", matched, errAuthenticate)
|
||||
}
|
||||
if authorization.Principal != response.Session.ID {
|
||||
t.Fatalf("principal = %q, want %q", authorization.Principal, response.Session.ID)
|
||||
}
|
||||
if authorization.IssuerPrincipal != "issuer-key" || authorization.IssuerProvider != "static" {
|
||||
t.Fatalf("issuer = %q/%q", authorization.IssuerProvider, authorization.IssuerPrincipal)
|
||||
}
|
||||
if got := modelFromJSON(authorization.Session); got != defaultLiveModel {
|
||||
t.Fatalf("upstream session model = %q, want %q", got, defaultLiveModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardRealtimeCallMapsModelAndLocation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
manager := auth.NewManager(nil, nil, nil)
|
||||
executor := &captureExecutor{responseBody: io.NopCloser(strings.NewReader("v=0\r\n"))}
|
||||
manager.RegisterExecutor(executor)
|
||||
if _, errRegister := manager.Register(context.Background(), &auth.Auth{
|
||||
ID: "codex-oauth",
|
||||
Provider: "codex",
|
||||
Status: auth.StatusActive,
|
||||
Metadata: map[string]any{"access_token": "oauth-token"},
|
||||
}); errRegister != nil {
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
handler := NewHandler(manager, nil)
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/calls", handler.Handle)
|
||||
|
||||
const boundary = "standard-realtime-boundary"
|
||||
body := multipartBody(boundary, "v=0\r\n", `{"type":"realtime","model":"gpt-realtime"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/realtime/calls", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
|
||||
}
|
||||
if recorder.Header().Get("Location") != "/v1/realtime/calls/call-123" {
|
||||
t.Fatalf("Location = %q", recorder.Header().Get("Location"))
|
||||
}
|
||||
if got := modelFromJSON(executor.body); got != defaultLiveModel {
|
||||
t.Fatalf("upstream model = %q, want %q; body=%s", got, defaultLiveModel, executor.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSecretStoreRejectsExpiredToken(t *testing.T) {
|
||||
store := newClientSecretStore()
|
||||
now := time.Unix(1700000000, 0)
|
||||
store.now = func() time.Time { return now }
|
||||
token, _, _, errCreate := store.create(json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex"}`), time.Minute, "issuer", "test")
|
||||
if errCreate != nil {
|
||||
t.Fatalf("create() error = %v", errCreate)
|
||||
}
|
||||
if _, errAuthenticate := store.authenticate(token); errAuthenticate != nil {
|
||||
t.Fatalf("authenticate() error = %v", errAuthenticate)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if _, errAuthenticate := store.authenticate(token); errAuthenticate == nil {
|
||||
t.Fatal("authenticate() accepted expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClientSecretSessionHandlesWhitespaceNullAndRejectsArrays(t *testing.T) {
|
||||
clientSession, upstreamSession, errNormalize := normalizeClientSecretSession(json.RawMessage(" null \n"))
|
||||
if errNormalize != nil {
|
||||
t.Fatalf("normalize whitespace null: %v", errNormalize)
|
||||
}
|
||||
if modelFromJSON(clientSession) != "gpt-realtime" || modelFromJSON(upstreamSession) != defaultLiveModel {
|
||||
t.Fatalf("client=%s upstream=%s", clientSession, upstreamSession)
|
||||
}
|
||||
if _, _, errNormalize = normalizeClientSecretSession(json.RawMessage(`[]`)); errNormalize == nil {
|
||||
t.Fatal("normalize accepted an array session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadClientSecretBodyRejectsOversizedSession(t *testing.T) {
|
||||
_, errRead := readClientSecretBody(bytes.NewReader(make([]byte, clientSecretMaxBodySize+1)))
|
||||
if !errors.Is(errRead, errBodyTooLarge) {
|
||||
t.Fatalf("readClientSecretBody() error = %v", errRead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClientSecretRejectsUnsupportedSessionType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &Handler{clientSecrets: newClientSecretStore()}
|
||||
router := gin.New()
|
||||
router.POST("/v1/realtime/client_secrets", handler.CreateClientSecret)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/realtime/client_secrets", strings.NewReader(`{"session":{"type":"transcription","model":"gpt-4o-transcribe"}}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusNotImplemented, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "realtime_capability_not_supported") {
|
||||
t.Fatalf("body = %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveSelectionHeadersRemoveLocalClientSecret(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/realtime/calls", nil)
|
||||
ginContext.Request.Header.Set("Authorization", "Bearer ek_secret")
|
||||
ginContext.Request.Header.Set("OpenAI-Safety-Identifier", "safe-user")
|
||||
ginContext.Set(ClientSecretPrincipalContextKey, "sess_123")
|
||||
headers := liveSelectionHeaders(ginContext)
|
||||
if headers.Get("Authorization") != "" {
|
||||
t.Fatalf("Authorization leaked: %q", headers.Get("Authorization"))
|
||||
}
|
||||
if headers.Get("OpenAI-Safety-Identifier") != "safe-user" {
|
||||
t.Fatalf("safety identifier = %q", headers.Get("OpenAI-Safety-Identifier"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebandRejectsClientSecretScopeMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(auth.NewManager(nil, nil, nil), nil)
|
||||
handler.sessions.put("call-123", liveSession{
|
||||
authID: "codex-oauth",
|
||||
model: defaultLiveModel,
|
||||
clientSecretPrincipal: "sess_expected",
|
||||
})
|
||||
router := gin.New()
|
||||
router.GET("/v1/realtime/calls/:call_id", func(c *gin.Context) {
|
||||
c.Set(ClientSecretPrincipalContextKey, "sess_other")
|
||||
c.Next()
|
||||
}, handler.HandleSideband)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/realtime/calls/call-123", nil)
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "websocket")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusForbidden, recorder.Body.String())
|
||||
}
|
||||
claimed, claim := handler.sessions.claim("call-123")
|
||||
if claim != sessionClaimAcquired {
|
||||
t.Fatalf("session claim = %v", claim)
|
||||
}
|
||||
handler.sessions.release(claimed)
|
||||
}
|
||||
|
||||
func TestSidebandRejectsStandardPrincipalScopeMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(auth.NewManager(nil, nil, nil), nil)
|
||||
handler.sessions.put("call-123", liveSession{
|
||||
authID: "codex-oauth",
|
||||
model: defaultLiveModel,
|
||||
ownerPrincipal: "owner-key",
|
||||
ownerProvider: "static",
|
||||
})
|
||||
router := gin.New()
|
||||
router.GET("/v1/realtime/calls/:call_id", func(c *gin.Context) {
|
||||
c.Set("userApiKey", "other-key")
|
||||
c.Set("accessProvider", "static")
|
||||
c.Next()
|
||||
}, handler.HandleSideband)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/realtime/calls/call-123", nil)
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "websocket")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusForbidden, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClientSecretCallSession(t *testing.T) {
|
||||
session := json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex","instructions":"help"}`)
|
||||
body, contentType, model, errApply := applyClientSecretCallSession([]byte("v=0\r\n"), "application/sdp", defaultLiveModel, session)
|
||||
if errApply != nil {
|
||||
t.Fatalf("applyClientSecretCallSession() error = %v", errApply)
|
||||
}
|
||||
if contentType != "application/json" || model != defaultLiveModel {
|
||||
t.Fatalf("contentType=%q model=%q", contentType, model)
|
||||
}
|
||||
var payload struct {
|
||||
SDP string `json:"sdp"`
|
||||
Session struct {
|
||||
Instructions string `json:"instructions"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal body: %v", errUnmarshal)
|
||||
}
|
||||
if payload.SDP != "v=0\r\n" || payload.Session.Instructions != "help" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
840
backend/internal/client/codex/live/live.go
Normal file
840
backend/internal/client/codex/live/live.go
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
// 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())
|
||||
}
|
||||
1109
backend/internal/client/codex/live/live_test.go
Normal file
1109
backend/internal/client/codex/live/live_test.go
Normal file
File diff suppressed because it is too large
Load diff
887
backend/internal/client/codex/live/media.go
Normal file
887
backend/internal/client/codex/live/media.go
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
realtimeDataChannelLabel = "oai-events"
|
||||
mediaDataQueueSize = 64
|
||||
mediaDataMessageMaxSize = 256 << 10
|
||||
mediaDataBufferedMaxSize = 1 << 20
|
||||
)
|
||||
|
||||
var opusCodec = webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeOpus,
|
||||
ClockRate: 48000,
|
||||
Channels: 2,
|
||||
SDPFmtpLine: "minptime=10;useinbandfec=1",
|
||||
}
|
||||
|
||||
type mediaRelaySession interface {
|
||||
AcceptUpstreamAnswer(context.Context, string) (string, error)
|
||||
SetCallID(string)
|
||||
SetCloseHandler(func(string))
|
||||
Close() error
|
||||
CloseWithReason(string) error
|
||||
}
|
||||
|
||||
type mediaRelayFactory interface {
|
||||
NewSession(context.Context, string, mediaSessionRoute) (mediaRelaySession, string, error)
|
||||
}
|
||||
|
||||
type mediaSessionRoute struct {
|
||||
proxyURL string
|
||||
credential string
|
||||
authIndex string
|
||||
}
|
||||
|
||||
type pionMediaRelay struct {
|
||||
downstreamAPI *webrtc.API
|
||||
upstreamAPI *webrtc.API
|
||||
proxyUpstreamAPI *webrtc.API
|
||||
configuration webrtc.Configuration
|
||||
limiter *mediaSessionLimiter
|
||||
}
|
||||
|
||||
type mediaSessionLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
active int
|
||||
}
|
||||
|
||||
type pionMediaSession struct {
|
||||
downstream *webrtc.PeerConnection
|
||||
upstream *webrtc.PeerConnection
|
||||
bridge *dataChannelBridge
|
||||
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
failureOnce sync.Once
|
||||
handlerMu sync.Mutex
|
||||
onClose func(string)
|
||||
failureReason string
|
||||
handlerCalled bool
|
||||
mediaSessionID string
|
||||
callID string
|
||||
releaseSlot func()
|
||||
|
||||
proxyDialer proxy.ContextDialer
|
||||
proxyScheme string
|
||||
credential string
|
||||
authIndex string
|
||||
forwardingLogOnce sync.Once
|
||||
localOffer string
|
||||
tunnelsMu sync.Mutex
|
||||
tunnels []*tcpCandidateTunnel
|
||||
}
|
||||
|
||||
type dataChannelMessage struct {
|
||||
data []byte
|
||||
isString bool
|
||||
}
|
||||
|
||||
type dataChannelPipe struct {
|
||||
name string
|
||||
done <-chan struct{}
|
||||
queue chan dataChannelMessage
|
||||
ready chan struct{}
|
||||
readyOnce sync.Once
|
||||
writable chan struct{}
|
||||
destination *webrtc.DataChannel
|
||||
mu sync.RWMutex
|
||||
onError func(error)
|
||||
}
|
||||
|
||||
type dataChannelBridge struct {
|
||||
done <-chan struct{}
|
||||
downToUp *dataChannelPipe
|
||||
upToDown *dataChannelPipe
|
||||
closeOnce sync.Once
|
||||
downstreamMu sync.Mutex
|
||||
downstream *webrtc.DataChannel
|
||||
upstreamMu sync.Mutex
|
||||
upstream *webrtc.DataChannel
|
||||
}
|
||||
|
||||
func newPionMediaRelay(relayConfig config.CodexLiveMediaRelayConfig) (*pionMediaRelay, error) {
|
||||
return newPionMediaRelayWithLimiter(relayConfig, &mediaSessionLimiter{})
|
||||
}
|
||||
|
||||
func newPionMediaRelayWithLimiter(relayConfig config.CodexLiveMediaRelayConfig, limiter *mediaSessionLimiter) (*pionMediaRelay, error) {
|
||||
if errValidate := relayConfig.Validate(); errValidate != nil {
|
||||
return nil, errValidate
|
||||
}
|
||||
downstreamAPI, errAPI := newPionAPI(relayConfig, relayConfig.DisablePrivateRemoteIPs)
|
||||
if errAPI != nil {
|
||||
return nil, errAPI
|
||||
}
|
||||
upstreamAPI, errAPI := newPionAPI(relayConfig, false)
|
||||
if errAPI != nil {
|
||||
return nil, errAPI
|
||||
}
|
||||
proxyUpstreamAPI, errAPI := newPionProxyAPI(relayConfig)
|
||||
if errAPI != nil {
|
||||
return nil, errAPI
|
||||
}
|
||||
iceServers := make([]webrtc.ICEServer, 0, len(relayConfig.ICEServers))
|
||||
for _, server := range relayConfig.ICEServers {
|
||||
urls := make([]string, 0, len(server.URLs))
|
||||
for _, rawURL := range server.URLs {
|
||||
urls = append(urls, strings.TrimSpace(rawURL))
|
||||
}
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: urls,
|
||||
Username: server.Username,
|
||||
Credential: server.Credential,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
})
|
||||
}
|
||||
if limiter == nil {
|
||||
limiter = &mediaSessionLimiter{}
|
||||
}
|
||||
limiter.setLimit(relayConfig.EffectiveMaxSessions())
|
||||
return &pionMediaRelay{
|
||||
downstreamAPI: downstreamAPI,
|
||||
upstreamAPI: upstreamAPI,
|
||||
proxyUpstreamAPI: proxyUpstreamAPI,
|
||||
configuration: webrtc.Configuration{ICEServers: iceServers},
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *mediaSessionLimiter) setLimit(limit int) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.limit = limit
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *mediaSessionLimiter) acquire() bool {
|
||||
if l == nil {
|
||||
return false
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.limit <= 0 || l.active >= l.limit {
|
||||
return false
|
||||
}
|
||||
l.active++
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *mediaSessionLimiter) release() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
if l.active > 0 {
|
||||
l.active--
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func newPionAPI(relayConfig config.CodexLiveMediaRelayConfig, filterPrivateRemoteIPs bool) (*webrtc.API, error) {
|
||||
return newPionAPIWithOptions(relayConfig, filterPrivateRemoteIPs, false)
|
||||
}
|
||||
|
||||
func newPionProxyAPI(relayConfig config.CodexLiveMediaRelayConfig) (*webrtc.API, error) {
|
||||
return newPionAPIWithOptions(relayConfig, false, true)
|
||||
}
|
||||
|
||||
func newPionAPIWithOptions(relayConfig config.CodexLiveMediaRelayConfig, filterPrivateRemoteIPs, loopbackOnly bool) (*webrtc.API, error) {
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if errRegister := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
||||
RTPCodecCapability: opusCodec,
|
||||
PayloadType: 111,
|
||||
}, webrtc.RTPCodecTypeAudio); errRegister != nil {
|
||||
return nil, fmt.Errorf("register Opus codec: %w", errRegister)
|
||||
}
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
if errRegister := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); errRegister != nil {
|
||||
return nil, fmt.Errorf("register WebRTC interceptors: %w", errRegister)
|
||||
}
|
||||
settingEngine := webrtc.SettingEngine{}
|
||||
if !loopbackOnly {
|
||||
if relayConfig.UDPPortMin != 0 {
|
||||
if errPorts := settingEngine.SetEphemeralUDPPortRange(relayConfig.UDPPortMin, relayConfig.UDPPortMax); errPorts != nil {
|
||||
return nil, fmt.Errorf("configure WebRTC UDP port range: %w", errPorts)
|
||||
}
|
||||
}
|
||||
if publicIP := strings.TrimSpace(relayConfig.PublicIP); publicIP != "" {
|
||||
settingEngine.SetNAT1To1IPs([]string{publicIP}, webrtc.ICECandidateTypeHost)
|
||||
}
|
||||
}
|
||||
if filterPrivateRemoteIPs {
|
||||
settingEngine.SetRemoteIPFilter(isPublicRemoteIP)
|
||||
}
|
||||
if loopbackOnly {
|
||||
settingEngine.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4,
|
||||
webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4,
|
||||
webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
settingEngine.SetIncludeLoopbackCandidate(true)
|
||||
settingEngine.SetIPFilter(func(ip net.IP) bool {
|
||||
return ip != nil && ip.IsLoopback()
|
||||
})
|
||||
}
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(mediaEngine),
|
||||
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||
webrtc.WithSettingEngine(settingEngine),
|
||||
), nil
|
||||
}
|
||||
|
||||
func isPublicRemoteIP(ip net.IP) bool {
|
||||
return ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() && !ip.IsPrivate() &&
|
||||
!ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast()
|
||||
}
|
||||
|
||||
func (r *pionMediaRelay) NewSession(ctx context.Context, clientOffer string, route mediaSessionRoute) (mediaRelaySession, string, error) {
|
||||
if r == nil || r.downstreamAPI == nil || r.upstreamAPI == nil || r.proxyUpstreamAPI == nil || r.limiter == nil {
|
||||
return nil, "", errors.New("Codex live media relay unavailable")
|
||||
}
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return nil, "", errContext
|
||||
}
|
||||
builtProxyDialer, proxyMode, errProxy := proxyutil.BuildDialer(route.proxyURL)
|
||||
if errProxy != nil {
|
||||
return nil, "", fmt.Errorf("configure Codex live remote TCP proxy: %w", errProxy)
|
||||
}
|
||||
proxied := proxyMode == proxyutil.ModeProxy
|
||||
var proxyDialer proxy.ContextDialer
|
||||
if proxied {
|
||||
contextDialer, ok := builtProxyDialer.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil, "", errors.New("Codex live remote TCP proxy does not support cancellation")
|
||||
}
|
||||
proxyDialer = contextDialer
|
||||
}
|
||||
if !r.limiter.acquire() {
|
||||
return nil, "", errors.New("Codex live media relay capacity exhausted")
|
||||
}
|
||||
releaseSlot := r.limiter.release
|
||||
downstream, errDownstream := r.downstreamAPI.NewPeerConnection(r.configuration)
|
||||
if errDownstream != nil {
|
||||
releaseSlot()
|
||||
return nil, "", fmt.Errorf("create downstream PeerConnection: %w", errDownstream)
|
||||
}
|
||||
upstreamAPI := r.upstreamAPI
|
||||
upstreamConfiguration := r.configuration
|
||||
if proxied {
|
||||
upstreamAPI = r.proxyUpstreamAPI
|
||||
upstreamConfiguration.ICEServers = nil
|
||||
}
|
||||
upstream, errUpstream := upstreamAPI.NewPeerConnection(upstreamConfiguration)
|
||||
if errUpstream != nil {
|
||||
releaseSlot()
|
||||
if errClose := downstream.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close downstream PeerConnection after setup error")
|
||||
}
|
||||
return nil, "", fmt.Errorf("create upstream PeerConnection: %w", errUpstream)
|
||||
}
|
||||
|
||||
session := &pionMediaSession{
|
||||
downstream: downstream,
|
||||
upstream: upstream,
|
||||
done: make(chan struct{}),
|
||||
mediaSessionID: uuid.NewString(),
|
||||
releaseSlot: releaseSlot,
|
||||
proxyDialer: proxyDialer,
|
||||
proxyScheme: proxyScheme(route.proxyURL),
|
||||
credential: strings.TrimSpace(route.credential),
|
||||
authIndex: strings.TrimSpace(route.authIndex),
|
||||
}
|
||||
session.bridge = newDataChannelBridge(session.done, func(err error) {
|
||||
session.fail("data_channel_failed", err)
|
||||
})
|
||||
session.installStateHandlers()
|
||||
log.WithFields(session.logFields("session")).Info("codex live WebRTC media session created")
|
||||
|
||||
if errRemote := downstream.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: clientOffer,
|
||||
}); errRemote != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("set downstream WebRTC offer: %w", errRemote)
|
||||
}
|
||||
|
||||
toDesktop, errTrack := webrtc.NewTrackLocalStaticRTP(opusCodec, "audio", "codex-live")
|
||||
if errTrack != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("create downstream audio track: %w", errTrack)
|
||||
}
|
||||
downstreamSender, errTrack := downstream.AddTrack(toDesktop)
|
||||
if errTrack != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("add downstream audio track: %w", errTrack)
|
||||
}
|
||||
go drainRTCP("downstream", downstreamSender, session.done)
|
||||
|
||||
toOpenAI, errTrack := webrtc.NewTrackLocalStaticRTP(opusCodec, "audio", "codex-live")
|
||||
if errTrack != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("create upstream audio track: %w", errTrack)
|
||||
}
|
||||
upstreamSender, errTrack := upstream.AddTrack(toOpenAI)
|
||||
if errTrack != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("add upstream audio track: %w", errTrack)
|
||||
}
|
||||
go drainRTCP("upstream", upstreamSender, session.done)
|
||||
|
||||
downstream.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
if !strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus) {
|
||||
return
|
||||
}
|
||||
go relayRTP("downstream-to-upstream", track, toOpenAI, session.done)
|
||||
})
|
||||
upstream.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
if !strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus) {
|
||||
return
|
||||
}
|
||||
go relayRTP("upstream-to-downstream", track, toDesktop, session.done)
|
||||
})
|
||||
downstream.OnDataChannel(func(channel *webrtc.DataChannel) {
|
||||
if channel.Label() != realtimeDataChannelLabel {
|
||||
if errClose := channel.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close unsupported downstream DataChannel")
|
||||
}
|
||||
return
|
||||
}
|
||||
session.bridge.attachDownstream(channel)
|
||||
})
|
||||
upstreamChannel, errChannel := upstream.CreateDataChannel(realtimeDataChannelLabel, nil)
|
||||
if errChannel != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("create upstream DataChannel: %w", errChannel)
|
||||
}
|
||||
session.bridge.attachUpstream(upstreamChannel)
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(upstream)
|
||||
offer, errOffer := upstream.CreateOffer(nil)
|
||||
if errOffer != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("create upstream WebRTC offer: %w", errOffer)
|
||||
}
|
||||
if errLocal := upstream.SetLocalDescription(offer); errLocal != nil {
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("set upstream WebRTC offer: %w", errLocal)
|
||||
}
|
||||
select {
|
||||
case <-gatherComplete:
|
||||
case <-ctx.Done():
|
||||
_ = session.Close()
|
||||
return nil, "", fmt.Errorf("gather upstream WebRTC candidates: %w", ctx.Err())
|
||||
}
|
||||
localDescription := upstream.LocalDescription()
|
||||
if localDescription == nil || strings.TrimSpace(localDescription.SDP) == "" {
|
||||
_ = session.Close()
|
||||
return nil, "", errors.New("upstream WebRTC offer is empty")
|
||||
}
|
||||
session.localOffer = localDescription.SDP
|
||||
return session, localDescription.SDP, nil
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) AcceptUpstreamAnswer(ctx context.Context, upstreamAnswer string) (string, error) {
|
||||
if s == nil || s.upstream == nil || s.downstream == nil {
|
||||
return "", errors.New("Codex live media session unavailable")
|
||||
}
|
||||
answerToApply := upstreamAnswer
|
||||
if s.proxyDialer != nil {
|
||||
rewrittenAnswer, tunnels, errProxy := prepareProxiedUpstreamAnswer(upstreamAnswer, s.localOffer, s.proxyDialer)
|
||||
if errProxy != nil {
|
||||
return "", errProxy
|
||||
}
|
||||
for _, tunnel := range tunnels {
|
||||
tunnel.setForwardingStartedHandler(s.logForwardingStarted)
|
||||
}
|
||||
if !s.installCandidateTunnels(tunnels) {
|
||||
errClosed := errors.New("Codex live media session closed while configuring TCP proxy")
|
||||
if errClose := closeCandidateTunnels(tunnels); errClose != nil {
|
||||
return "", errors.Join(errClosed, fmt.Errorf("close TCP candidate tunnels: %w", errClose))
|
||||
}
|
||||
return "", errClosed
|
||||
}
|
||||
answerToApply = rewrittenAnswer
|
||||
}
|
||||
if errRemote := s.upstream.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: answerToApply,
|
||||
}); errRemote != nil {
|
||||
errSetRemote := fmt.Errorf("set upstream WebRTC answer: %w", errRemote)
|
||||
if errClose := s.closeCandidateTunnels(); errClose != nil {
|
||||
return "", errors.Join(errSetRemote, fmt.Errorf("close TCP candidate tunnels: %w", errClose))
|
||||
}
|
||||
return "", errSetRemote
|
||||
}
|
||||
gatherComplete := webrtc.GatheringCompletePromise(s.downstream)
|
||||
answer, errAnswer := s.downstream.CreateAnswer(nil)
|
||||
if errAnswer != nil {
|
||||
return "", fmt.Errorf("create downstream WebRTC answer: %w", errAnswer)
|
||||
}
|
||||
if errLocal := s.downstream.SetLocalDescription(answer); errLocal != nil {
|
||||
return "", fmt.Errorf("set downstream WebRTC answer: %w", errLocal)
|
||||
}
|
||||
select {
|
||||
case <-gatherComplete:
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("gather downstream WebRTC candidates: %w", ctx.Err())
|
||||
}
|
||||
localDescription := s.downstream.LocalDescription()
|
||||
if localDescription == nil || strings.TrimSpace(localDescription.SDP) == "" {
|
||||
return "", errors.New("downstream WebRTC answer is empty")
|
||||
}
|
||||
return localDescription.SDP, nil
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) installCandidateTunnels(tunnels []*tcpCandidateTunnel) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.tunnelsMu.Lock()
|
||||
defer s.tunnelsMu.Unlock()
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
s.tunnels = tunnels
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) closeCandidateTunnels() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.tunnelsMu.Lock()
|
||||
tunnels := s.tunnels
|
||||
s.tunnels = nil
|
||||
s.tunnelsMu.Unlock()
|
||||
return closeCandidateTunnels(tunnels)
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) SetCallID(callID string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.handlerMu.Lock()
|
||||
s.callID = strings.TrimSpace(callID)
|
||||
s.handlerMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) logFields(peer string) log.Fields {
|
||||
fields := log.Fields{
|
||||
"media_session_id": s.mediaSessionID,
|
||||
"peer": peer,
|
||||
}
|
||||
s.handlerMu.Lock()
|
||||
callID := s.callID
|
||||
s.handlerMu.Unlock()
|
||||
if callID != "" {
|
||||
fields["call_id"] = callID
|
||||
}
|
||||
if s.proxyDialer != nil && (peer == "remote" || peer == "session") {
|
||||
fields["remote_transport"] = "tcp"
|
||||
fields["proxy_scheme"] = s.proxyScheme
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) forwardingLogFields() log.Fields {
|
||||
fields := s.logFields("remote")
|
||||
if s.authIndex != "" {
|
||||
fields["auth_index"] = s.authIndex
|
||||
}
|
||||
if s.credential != "" {
|
||||
fields["credential"] = s.credential
|
||||
}
|
||||
if s.proxyDialer != nil {
|
||||
fields["connection"] = "via " + s.proxyScheme + " proxy"
|
||||
fields["remote_transport"] = "tcp"
|
||||
} else {
|
||||
fields["connection"] = "direct"
|
||||
fields["remote_transport"] = "ice"
|
||||
}
|
||||
if s.upstream != nil {
|
||||
fields["state"] = s.upstream.ConnectionState().String()
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) logForwardingStarted() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.forwardingLogOnce.Do(func() {
|
||||
log.WithFields(s.forwardingLogFields()).Info("codex live remote media forwarding started")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) SetCloseHandler(handler func(string)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.handlerMu.Lock()
|
||||
s.onClose = handler
|
||||
reason := s.failureReason
|
||||
callHandler := handler != nil && reason != "" && !s.handlerCalled
|
||||
if callHandler {
|
||||
s.handlerCalled = true
|
||||
}
|
||||
s.handlerMu.Unlock()
|
||||
if callHandler {
|
||||
handler(reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) Close() error {
|
||||
return s.CloseWithReason("closed")
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) CloseWithReason(reason string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.closeOnce.Do(func() {
|
||||
fields := s.logFields("session")
|
||||
fields["reason"] = reason
|
||||
log.WithFields(fields).Info("codex live WebRTC media session closing")
|
||||
close(s.done)
|
||||
if s.bridge != nil {
|
||||
s.bridge.close()
|
||||
}
|
||||
var closeErrors []error
|
||||
if errClose := s.closeCandidateTunnels(); errClose != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("close TCP candidate tunnels: %w", errClose))
|
||||
}
|
||||
if errClose := s.closePeerConnection("local", s.downstream); errClose != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("close downstream PeerConnection: %w", errClose))
|
||||
}
|
||||
if errClose := s.closePeerConnection("remote", s.upstream); errClose != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("close upstream PeerConnection: %w", errClose))
|
||||
}
|
||||
if s.releaseSlot != nil {
|
||||
s.releaseSlot()
|
||||
}
|
||||
s.closeErr = errors.Join(closeErrors...)
|
||||
if s.closeErr != nil {
|
||||
log.WithFields(fields).WithError(s.closeErr).Warn("codex live WebRTC media session closed with errors")
|
||||
} else {
|
||||
log.WithFields(fields).Info("codex live WebRTC media session closed")
|
||||
}
|
||||
})
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) closePeerConnection(peer string, connection *webrtc.PeerConnection) error {
|
||||
if connection == nil {
|
||||
return nil
|
||||
}
|
||||
fields := s.logFields(peer)
|
||||
fields["state_before"] = connection.ConnectionState().String()
|
||||
errClose := connection.Close()
|
||||
fields["state_after"] = connection.ConnectionState().String()
|
||||
if errClose != nil {
|
||||
log.WithFields(fields).WithError(errClose).Warn("codex live WebRTC peer close failed")
|
||||
return errClose
|
||||
}
|
||||
log.WithFields(fields).Info("codex live WebRTC peer closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) installStateHandlers() {
|
||||
handle := func(peer, reasonPrefix string) func(webrtc.PeerConnectionState) {
|
||||
return func(state webrtc.PeerConnectionState) {
|
||||
fields := s.logFields(peer)
|
||||
fields["state"] = state.String()
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateConnecting:
|
||||
log.WithFields(fields).Info("codex live WebRTC peer connecting")
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
log.WithFields(fields).Info("codex live WebRTC peer connected")
|
||||
if peer == "remote" {
|
||||
s.logForwardingStarted()
|
||||
}
|
||||
case webrtc.PeerConnectionStateDisconnected:
|
||||
log.WithFields(fields).Warn("codex live WebRTC peer disconnected")
|
||||
case webrtc.PeerConnectionStateFailed:
|
||||
log.WithFields(fields).Warn("codex live WebRTC peer failed")
|
||||
s.fail(reasonPrefix+"_failed", fmt.Errorf("%s PeerConnection failed", reasonPrefix))
|
||||
case webrtc.PeerConnectionStateClosed:
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
log.WithFields(fields).Info("codex live WebRTC peer closed by remote")
|
||||
s.fail(reasonPrefix+"_closed", fmt.Errorf("%s PeerConnection closed", reasonPrefix))
|
||||
}
|
||||
default:
|
||||
log.WithFields(fields).Debug("codex live WebRTC peer state changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
s.downstream.OnConnectionStateChange(handle("local", "downstream"))
|
||||
s.upstream.OnConnectionStateChange(handle("remote", "upstream"))
|
||||
}
|
||||
|
||||
func (s *pionMediaSession) fail(reason string, err error) {
|
||||
s.failureOnce.Do(func() {
|
||||
if err != nil {
|
||||
log.WithFields(s.logFields("session")).WithField("reason", reason).WithError(err).Warn("codex live WebRTC media session failed")
|
||||
}
|
||||
if errClose := s.CloseWithReason(reason); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close failed session")
|
||||
}
|
||||
s.handlerMu.Lock()
|
||||
s.failureReason = reason
|
||||
handler := s.onClose
|
||||
callHandler := handler != nil && !s.handlerCalled
|
||||
if callHandler {
|
||||
s.handlerCalled = true
|
||||
}
|
||||
s.handlerMu.Unlock()
|
||||
if callHandler {
|
||||
handler(reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func relayRTP(name string, source *webrtc.TrackRemote, destination *webrtc.TrackLocalStaticRTP, done <-chan struct{}) {
|
||||
for {
|
||||
packet, _, errRead := source.ReadRTP()
|
||||
if errRead != nil {
|
||||
if !isClosedMediaError(errRead, done) {
|
||||
log.WithError(errRead).Debugf("codex live media: %s RTP read stopped", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
normalizeRTPPacket(packet)
|
||||
if errWrite := destination.WriteRTP(packet); errWrite != nil {
|
||||
if !isClosedMediaError(errWrite, done) {
|
||||
log.WithError(errWrite).Debugf("codex live media: %s RTP write stopped", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRTPPacket(packet *rtp.Packet) {
|
||||
if packet == nil {
|
||||
return
|
||||
}
|
||||
packet.Extension = false
|
||||
packet.ExtensionProfile = 0
|
||||
packet.Extensions = nil
|
||||
}
|
||||
|
||||
func drainRTCP(name string, sender *webrtc.RTPSender, done <-chan struct{}) {
|
||||
for {
|
||||
if _, _, errRead := sender.ReadRTCP(); errRead != nil {
|
||||
if !isClosedMediaError(errRead, done) {
|
||||
log.WithError(errRead).Debugf("codex live media: %s RTCP reader stopped", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isClosedMediaError(err error, done <-chan struct{}) bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
}
|
||||
return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
||||
func newDataChannelBridge(done <-chan struct{}, onError func(error)) *dataChannelBridge {
|
||||
bridge := &dataChannelBridge{done: done}
|
||||
bridge.downToUp = newDataChannelPipe("downstream-to-upstream", done, onError)
|
||||
bridge.upToDown = newDataChannelPipe("upstream-to-downstream", done, onError)
|
||||
return bridge
|
||||
}
|
||||
|
||||
func newDataChannelPipe(name string, done <-chan struct{}, onError func(error)) *dataChannelPipe {
|
||||
pipe := &dataChannelPipe{
|
||||
name: name,
|
||||
done: done,
|
||||
queue: make(chan dataChannelMessage, mediaDataQueueSize),
|
||||
ready: make(chan struct{}),
|
||||
writable: make(chan struct{}, 1),
|
||||
onError: onError,
|
||||
}
|
||||
go pipe.run()
|
||||
return pipe
|
||||
}
|
||||
|
||||
func (b *dataChannelBridge) attachDownstream(channel *webrtc.DataChannel) {
|
||||
b.downstreamMu.Lock()
|
||||
if b.downstream != nil {
|
||||
b.downstreamMu.Unlock()
|
||||
if errClose := channel.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close duplicate downstream DataChannel")
|
||||
}
|
||||
return
|
||||
}
|
||||
b.downstream = channel
|
||||
b.downstreamMu.Unlock()
|
||||
b.upToDown.setDestination(channel)
|
||||
b.bindSource(channel, b.downToUp)
|
||||
}
|
||||
|
||||
func (b *dataChannelBridge) attachUpstream(channel *webrtc.DataChannel) {
|
||||
b.upstreamMu.Lock()
|
||||
if b.upstream != nil {
|
||||
b.upstreamMu.Unlock()
|
||||
if errClose := channel.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close duplicate upstream DataChannel")
|
||||
}
|
||||
return
|
||||
}
|
||||
b.upstream = channel
|
||||
b.upstreamMu.Unlock()
|
||||
b.downToUp.setDestination(channel)
|
||||
b.bindSource(channel, b.upToDown)
|
||||
}
|
||||
|
||||
func (b *dataChannelBridge) bindSource(channel *webrtc.DataChannel, destination *dataChannelPipe) {
|
||||
channel.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
if len(message.Data) > mediaDataMessageMaxSize {
|
||||
destination.reportError(fmt.Errorf("%s DataChannel message exceeds %d bytes", destination.name, mediaDataMessageMaxSize))
|
||||
return
|
||||
}
|
||||
payload := append([]byte(nil), message.Data...)
|
||||
select {
|
||||
case destination.queue <- dataChannelMessage{data: payload, isString: message.IsString}:
|
||||
case <-b.done:
|
||||
}
|
||||
})
|
||||
channel.OnError(func(err error) {
|
||||
destination.reportError(fmt.Errorf("%s DataChannel error: %w", destination.name, err))
|
||||
})
|
||||
channel.OnClose(func() {
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
default:
|
||||
destination.reportError(fmt.Errorf("%s DataChannel closed", destination.name))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (b *dataChannelBridge) close() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.closeOnce.Do(func() {
|
||||
b.downstreamMu.Lock()
|
||||
downstream := b.downstream
|
||||
b.downstreamMu.Unlock()
|
||||
if downstream != nil {
|
||||
if errClose := downstream.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close downstream DataChannel")
|
||||
}
|
||||
}
|
||||
b.upstreamMu.Lock()
|
||||
upstream := b.upstream
|
||||
b.upstreamMu.Unlock()
|
||||
if upstream != nil {
|
||||
if errClose := upstream.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close upstream DataChannel")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (p *dataChannelPipe) setDestination(channel *webrtc.DataChannel) {
|
||||
p.mu.Lock()
|
||||
p.destination = channel
|
||||
p.mu.Unlock()
|
||||
markReady := func() {
|
||||
p.readyOnce.Do(func() { close(p.ready) })
|
||||
}
|
||||
channel.SetBufferedAmountLowThreshold(mediaDataBufferedMaxSize / 2)
|
||||
channel.OnBufferedAmountLow(func() {
|
||||
select {
|
||||
case p.writable <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
channel.OnOpen(markReady)
|
||||
if channel.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
markReady()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *dataChannelPipe) run() {
|
||||
select {
|
||||
case <-p.ready:
|
||||
case <-p.done:
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case message := <-p.queue:
|
||||
p.mu.RLock()
|
||||
destination := p.destination
|
||||
p.mu.RUnlock()
|
||||
if destination == nil {
|
||||
p.reportError(fmt.Errorf("%s DataChannel destination unavailable", p.name))
|
||||
return
|
||||
}
|
||||
if !p.waitWritable(destination, len(message.data)) {
|
||||
return
|
||||
}
|
||||
var errSend error
|
||||
if message.isString {
|
||||
errSend = destination.SendText(string(message.data))
|
||||
} else {
|
||||
errSend = destination.Send(message.data)
|
||||
}
|
||||
if errSend != nil {
|
||||
p.reportError(fmt.Errorf("send %s DataChannel message: %w", p.name, errSend))
|
||||
return
|
||||
}
|
||||
case <-p.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *dataChannelPipe) waitWritable(destination *webrtc.DataChannel, messageSize int) bool {
|
||||
for destination.BufferedAmount()+uint64(messageSize) > mediaDataBufferedMaxSize {
|
||||
select {
|
||||
case <-p.writable:
|
||||
case <-p.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *dataChannelPipe) reportError(err error) {
|
||||
if p.onError != nil {
|
||||
p.onError(err)
|
||||
}
|
||||
}
|
||||
542
backend/internal/client/codex/live/media_test.go
Normal file
542
backend/internal/client/codex/live/media_test.go
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logtest "github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
func TestPionMediaRelaySelectsRemoteProxyMode(t *testing.T) {
|
||||
clientAPI := newTestWebRTCAPI(t)
|
||||
client, errClient := clientAPI.NewPeerConnection(webrtc.Configuration{})
|
||||
if errClient != nil {
|
||||
t.Fatalf("create client PeerConnection: %v", errClient)
|
||||
}
|
||||
defer closeTestPeerConnection(t, client)
|
||||
if _, errChannel := client.CreateDataChannel(realtimeDataChannelLabel, nil); errChannel != nil {
|
||||
t.Fatalf("create client DataChannel: %v", errChannel)
|
||||
}
|
||||
clientOffer := completeOffer(t, client)
|
||||
relay, errRelay := newPionMediaRelay(config.CodexLiveMediaRelayConfig{
|
||||
Enabled: true,
|
||||
PublicIP: "198.51.100.1",
|
||||
})
|
||||
if errRelay != nil {
|
||||
t.Fatalf("create media relay: %v", errRelay)
|
||||
}
|
||||
|
||||
for name, testCase := range map[string]struct {
|
||||
proxyURL string
|
||||
proxied bool
|
||||
}{
|
||||
"inherit": {proxyURL: ""},
|
||||
"direct": {proxyURL: "direct"},
|
||||
"HTTP": {proxyURL: "http://proxy.example:8080", proxied: true},
|
||||
"HTTPS": {proxyURL: "https://proxy.example:8443", proxied: true},
|
||||
"SOCKS5": {proxyURL: "socks5://proxy.example:1080", proxied: true},
|
||||
"SOCKS5H": {proxyURL: "socks5h://proxy.example:1080", proxied: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
session, upstreamOffer, errSession := relay.NewSession(context.Background(), clientOffer, mediaSessionRoute{proxyURL: testCase.proxyURL})
|
||||
if errSession != nil {
|
||||
t.Fatalf("create media session: %v", errSession)
|
||||
}
|
||||
pionSession, ok := session.(*pionMediaSession)
|
||||
if !ok {
|
||||
t.Fatalf("media session type = %T", session)
|
||||
}
|
||||
if got := pionSession.proxyDialer != nil; got != testCase.proxied {
|
||||
t.Fatalf("proxied = %t, want %t", got, testCase.proxied)
|
||||
}
|
||||
if testCase.proxied && !offerCandidatesAreLoopback(t, upstreamOffer) {
|
||||
t.Fatal("proxied upstream offer exposed a non-loopback candidate")
|
||||
}
|
||||
if errClose := session.Close(); errClose != nil {
|
||||
t.Fatalf("close media session: %v", errClose)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, _, errSession := relay.NewSession(context.Background(), clientOffer, mediaSessionRoute{proxyURL: "invalid-proxy"}); errSession == nil {
|
||||
t.Fatal("expected invalid proxy URL to fail media session creation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaForwardingStartedLogRedactsProxyCredentials(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
|
||||
hook := logtest.NewLocal(logger)
|
||||
defer logger.ReplaceHooks(previousHooks)
|
||||
|
||||
for name, testCase := range map[string]struct {
|
||||
proxyURL string
|
||||
connection string
|
||||
credential string
|
||||
}{
|
||||
"direct": {
|
||||
connection: "direct",
|
||||
credential: "Voice credential",
|
||||
},
|
||||
"HTTP": {
|
||||
proxyURL: "http://user:secret@proxy.example:8080",
|
||||
connection: "via http proxy",
|
||||
credential: "Voice credential",
|
||||
},
|
||||
"SOCKS5 without label": {
|
||||
proxyURL: "socks5://user:secret@proxy.example:1080",
|
||||
connection: "via socks5 proxy",
|
||||
credential: "auth-index",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
session := &pionMediaSession{
|
||||
mediaSessionID: "media-session-" + name,
|
||||
proxyScheme: proxyScheme(testCase.proxyURL),
|
||||
credential: testCase.credential,
|
||||
authIndex: "auth-index",
|
||||
}
|
||||
if testCase.proxyURL != "" {
|
||||
session.proxyDialer = &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
}
|
||||
earlyFields := session.logFields("session")
|
||||
for _, field := range []string{"auth_id", "auth_label", "auth_index", "credential", "connection"} {
|
||||
if _, exists := earlyFields[field]; exists {
|
||||
t.Fatalf("session log exposed forwarding-only field %q before forwarding started: %#v", field, earlyFields)
|
||||
}
|
||||
}
|
||||
session.logForwardingStarted()
|
||||
session.logForwardingStarted()
|
||||
|
||||
matching := 0
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Message != "codex live remote media forwarding started" || entry.Data["media_session_id"] != session.mediaSessionID {
|
||||
continue
|
||||
}
|
||||
matching++
|
||||
if entry.Data["connection"] != testCase.connection || entry.Data["credential"] != testCase.credential {
|
||||
t.Fatalf("forwarding fields = %#v", entry.Data)
|
||||
}
|
||||
serialized := fmt.Sprint(entry.Data)
|
||||
for _, secret := range []string{"user", "secret", "proxy.example"} {
|
||||
if strings.Contains(serialized, secret) {
|
||||
t.Fatalf("forwarding log leaked %q: %s", secret, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matching != 1 {
|
||||
t.Fatalf("forwarding log count = %d, want 1", matching)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPionMediaRelayBridgesAudioAndDataChannel(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
|
||||
previousLevel := logger.GetLevel()
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
hook := logtest.NewLocal(logger)
|
||||
defer func() {
|
||||
logger.ReplaceHooks(previousHooks)
|
||||
logger.SetLevel(previousLevel)
|
||||
}()
|
||||
clientAPI := newTestWebRTCAPI(t)
|
||||
client, errClient := clientAPI.NewPeerConnection(webrtc.Configuration{})
|
||||
if errClient != nil {
|
||||
t.Fatalf("create client PeerConnection: %v", errClient)
|
||||
}
|
||||
defer closeTestPeerConnection(t, client)
|
||||
clientDone := make(chan struct{})
|
||||
defer close(clientDone)
|
||||
|
||||
clientAudio, errTrack := webrtc.NewTrackLocalStaticRTP(opusCodec, "client-audio", "client")
|
||||
if errTrack != nil {
|
||||
t.Fatalf("create client audio track: %v", errTrack)
|
||||
}
|
||||
clientSender, errTrack := client.AddTrack(clientAudio)
|
||||
if errTrack != nil {
|
||||
t.Fatalf("add client audio track: %v", errTrack)
|
||||
}
|
||||
go drainRTCP("test-client", clientSender, clientDone)
|
||||
clientData, errData := client.CreateDataChannel(realtimeDataChannelLabel, nil)
|
||||
if errData != nil {
|
||||
t.Fatalf("create client DataChannel: %v", errData)
|
||||
}
|
||||
clientMessages := make(chan webrtc.DataChannelMessage, 4)
|
||||
clientData.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
message.Data = append([]byte(nil), message.Data...)
|
||||
clientMessages <- message
|
||||
})
|
||||
clientAudioMessages := make(chan []byte, 1)
|
||||
client.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
packet, _, errRead := track.ReadRTP()
|
||||
if errRead == nil {
|
||||
clientAudioMessages <- append([]byte(nil), packet.Payload...)
|
||||
}
|
||||
})
|
||||
|
||||
clientOffer := completeOffer(t, client)
|
||||
relayConfig := config.CodexLiveMediaRelayConfig{
|
||||
Enabled: true,
|
||||
MaxSessions: 1,
|
||||
DisablePrivateRemoteIPs: false,
|
||||
}
|
||||
relay, errRelay := newPionMediaRelay(relayConfig)
|
||||
if errRelay != nil {
|
||||
t.Fatalf("create media relay: %v", errRelay)
|
||||
}
|
||||
session, relayOffer, errSession := relay.NewSession(context.Background(), clientOffer, mediaSessionRoute{
|
||||
credential: "Voice credential",
|
||||
authIndex: "auth-index",
|
||||
})
|
||||
if errSession != nil {
|
||||
t.Fatalf("create media relay session: %v", errSession)
|
||||
}
|
||||
session.SetCallID("call-log-test")
|
||||
defer func() {
|
||||
if errClose := session.Close(); errClose != nil {
|
||||
t.Errorf("close media relay session: %v", errClose)
|
||||
}
|
||||
}()
|
||||
reloadedRelay, errRelay := newPionMediaRelayWithLimiter(relayConfig, relay.limiter)
|
||||
if errRelay != nil {
|
||||
t.Fatalf("reload media relay: %v", errRelay)
|
||||
}
|
||||
if _, _, errCapacity := reloadedRelay.NewSession(context.Background(), clientOffer, mediaSessionRoute{}); errCapacity == nil {
|
||||
t.Fatal("reloaded media relay bypassed the shared session capacity")
|
||||
}
|
||||
|
||||
upstreamAPI := newTestWebRTCAPI(t)
|
||||
upstream, errUpstream := upstreamAPI.NewPeerConnection(webrtc.Configuration{})
|
||||
if errUpstream != nil {
|
||||
t.Fatalf("create upstream PeerConnection: %v", errUpstream)
|
||||
}
|
||||
defer closeTestPeerConnection(t, upstream)
|
||||
upstreamDone := make(chan struct{})
|
||||
defer close(upstreamDone)
|
||||
|
||||
upstreamDataChannels := make(chan *webrtc.DataChannel, 1)
|
||||
upstreamMessages := make(chan webrtc.DataChannelMessage, 4)
|
||||
upstream.OnDataChannel(func(channel *webrtc.DataChannel) {
|
||||
if channel.Label() != realtimeDataChannelLabel {
|
||||
return
|
||||
}
|
||||
channel.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
message.Data = append([]byte(nil), message.Data...)
|
||||
upstreamMessages <- message
|
||||
})
|
||||
upstreamDataChannels <- channel
|
||||
})
|
||||
upstreamAudioMessages := make(chan []byte, 1)
|
||||
upstream.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
packet, _, errRead := track.ReadRTP()
|
||||
if errRead == nil {
|
||||
upstreamAudioMessages <- append([]byte(nil), packet.Payload...)
|
||||
}
|
||||
})
|
||||
if errRemote := upstream.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: relayOffer}); errRemote != nil {
|
||||
t.Fatalf("set upstream offer: %v", errRemote)
|
||||
}
|
||||
upstreamAudio, errTrack := webrtc.NewTrackLocalStaticRTP(opusCodec, "upstream-audio", "upstream")
|
||||
if errTrack != nil {
|
||||
t.Fatalf("create upstream audio track: %v", errTrack)
|
||||
}
|
||||
upstreamSender, errTrack := upstream.AddTrack(upstreamAudio)
|
||||
if errTrack != nil {
|
||||
t.Fatalf("add upstream audio track: %v", errTrack)
|
||||
}
|
||||
go drainRTCP("test-upstream", upstreamSender, upstreamDone)
|
||||
upstreamAnswer := completeAnswer(t, upstream)
|
||||
downstreamAnswer, errAnswer := session.AcceptUpstreamAnswer(context.Background(), upstreamAnswer)
|
||||
if errAnswer != nil {
|
||||
t.Fatalf("accept upstream answer: %v", errAnswer)
|
||||
}
|
||||
if errRemote := client.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: downstreamAnswer}); errRemote != nil {
|
||||
t.Fatalf("set client answer: %v", errRemote)
|
||||
}
|
||||
|
||||
upstreamData := receiveDataChannel(t, upstreamDataChannels)
|
||||
waitDataChannelOpen(t, clientData)
|
||||
waitDataChannelOpen(t, upstreamData)
|
||||
if errSend := clientData.SendText("from-client"); errSend != nil {
|
||||
t.Fatalf("send client DataChannel message: %v", errSend)
|
||||
}
|
||||
if got := receiveDataMessage(t, upstreamMessages); !got.IsString || string(got.Data) != "from-client" {
|
||||
t.Fatalf("upstream DataChannel message = %#v, want text from-client", got)
|
||||
}
|
||||
if errSend := upstreamData.SendText("from-upstream"); errSend != nil {
|
||||
t.Fatalf("send upstream DataChannel message: %v", errSend)
|
||||
}
|
||||
if got := receiveDataMessage(t, clientMessages); !got.IsString || string(got.Data) != "from-upstream" {
|
||||
t.Fatalf("client DataChannel message = %#v, want text from-upstream", got)
|
||||
}
|
||||
if errSend := clientData.Send([]byte{0x01, 0x02, 0x03}); errSend != nil {
|
||||
t.Fatalf("send client binary DataChannel message: %v", errSend)
|
||||
}
|
||||
if got := receiveDataMessage(t, upstreamMessages); got.IsString || string(got.Data) != string([]byte{0x01, 0x02, 0x03}) {
|
||||
t.Fatalf("upstream binary DataChannel message = %#v", got)
|
||||
}
|
||||
|
||||
clientPayload := []byte{0xf8, 0xff, 0xfe}
|
||||
sendTestRTP(t, clientAudio, clientPayload, upstreamAudioMessages)
|
||||
upstreamPayload := []byte{0xf8, 0xfe, 0xfd}
|
||||
sendTestRTP(t, upstreamAudio, upstreamPayload, clientAudioMessages)
|
||||
if errClose := session.Close(); errClose != nil {
|
||||
t.Fatalf("close media relay session for logging: %v", errClose)
|
||||
}
|
||||
replacementSession, _, errReplacement := reloadedRelay.NewSession(context.Background(), clientOffer, mediaSessionRoute{})
|
||||
if errReplacement != nil {
|
||||
t.Fatalf("shared capacity was not released: %v", errReplacement)
|
||||
}
|
||||
if errClose := replacementSession.CloseWithReason("test_complete"); errClose != nil {
|
||||
t.Fatalf("close replacement media session: %v", errClose)
|
||||
}
|
||||
for _, peer := range []string{"local", "remote"} {
|
||||
assertPeerLog(t, hook, "codex live WebRTC peer connected", peer, "call-log-test")
|
||||
assertPeerLog(t, hook, "codex live WebRTC peer closed", peer, "call-log-test")
|
||||
}
|
||||
assertForwardingLog(t, hook, "direct", "Voice credential", "auth-index", "connected")
|
||||
assertForwardingAfterRemoteConnected(t, hook)
|
||||
assertSessionLog(t, hook, "codex live WebRTC media session closed", "closed", "call-log-test")
|
||||
}
|
||||
|
||||
func TestIsPublicRemoteIP(t *testing.T) {
|
||||
for rawIP, want := range map[string]bool{
|
||||
"8.8.8.8": true,
|
||||
"2001:4860::1": true,
|
||||
"127.0.0.1": false,
|
||||
"10.0.0.1": false,
|
||||
"169.254.1.1": false,
|
||||
"224.0.0.1": false,
|
||||
"::1": false,
|
||||
"fc00::1": false,
|
||||
"fe80::1": false,
|
||||
"ff02::1": false,
|
||||
"0.0.0.0": false,
|
||||
} {
|
||||
if got := isPublicRemoteIP(net.ParseIP(rawIP)); got != want {
|
||||
t.Errorf("isPublicRemoteIP(%q) = %t, want %t", rawIP, got, want)
|
||||
}
|
||||
}
|
||||
if isPublicRemoteIP(nil) {
|
||||
t.Fatal("isPublicRemoteIP(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func offerCandidatesAreLoopback(t *testing.T, offer string) bool {
|
||||
t.Helper()
|
||||
lines := strings.Split(strings.ReplaceAll(offer, "\r\n", "\n"), "\n")
|
||||
candidateCount := 0
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "a=candidate:") {
|
||||
continue
|
||||
}
|
||||
candidateCount++
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "a=candidate:"))
|
||||
if len(fields) < 6 {
|
||||
t.Fatalf("malformed offer candidate: %q", line)
|
||||
}
|
||||
address := net.ParseIP(fields[4])
|
||||
if address == nil || !address.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return candidateCount > 0
|
||||
}
|
||||
|
||||
func newTestWebRTCAPI(t *testing.T) *webrtc.API {
|
||||
t.Helper()
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if errRegister := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
||||
RTPCodecCapability: opusCodec,
|
||||
PayloadType: 111,
|
||||
}, webrtc.RTPCodecTypeAudio); errRegister != nil {
|
||||
t.Fatalf("register test Opus codec: %v", errRegister)
|
||||
}
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
if errRegister := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); errRegister != nil {
|
||||
t.Fatalf("register test interceptors: %v", errRegister)
|
||||
}
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(mediaEngine),
|
||||
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||
)
|
||||
}
|
||||
|
||||
func completeOffer(t *testing.T, connection *webrtc.PeerConnection) string {
|
||||
t.Helper()
|
||||
gatherComplete := webrtc.GatheringCompletePromise(connection)
|
||||
offer, errOffer := connection.CreateOffer(nil)
|
||||
if errOffer != nil {
|
||||
t.Fatalf("create offer: %v", errOffer)
|
||||
}
|
||||
if errLocal := connection.SetLocalDescription(offer); errLocal != nil {
|
||||
t.Fatalf("set local offer: %v", errLocal)
|
||||
}
|
||||
select {
|
||||
case <-gatherComplete:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("offer ICE gathering did not complete")
|
||||
}
|
||||
return connection.LocalDescription().SDP
|
||||
}
|
||||
|
||||
func completeAnswer(t *testing.T, connection *webrtc.PeerConnection) string {
|
||||
t.Helper()
|
||||
gatherComplete := webrtc.GatheringCompletePromise(connection)
|
||||
answer, errAnswer := connection.CreateAnswer(nil)
|
||||
if errAnswer != nil {
|
||||
t.Fatalf("create answer: %v", errAnswer)
|
||||
}
|
||||
if errLocal := connection.SetLocalDescription(answer); errLocal != nil {
|
||||
t.Fatalf("set local answer: %v", errLocal)
|
||||
}
|
||||
select {
|
||||
case <-gatherComplete:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("answer ICE gathering did not complete")
|
||||
}
|
||||
return connection.LocalDescription().SDP
|
||||
}
|
||||
|
||||
func waitDataChannelOpen(t *testing.T, channel *webrtc.DataChannel) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if channel.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("DataChannel %q did not open", channel.Label())
|
||||
}
|
||||
|
||||
func receiveDataChannel(t *testing.T, channels <-chan *webrtc.DataChannel) *webrtc.DataChannel {
|
||||
t.Helper()
|
||||
select {
|
||||
case channel := <-channels:
|
||||
return channel
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upstream DataChannel was not created")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func receiveDataMessage(t *testing.T, messages <-chan webrtc.DataChannelMessage) webrtc.DataChannelMessage {
|
||||
t.Helper()
|
||||
select {
|
||||
case message := <-messages:
|
||||
return message
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("DataChannel message was not relayed")
|
||||
return webrtc.DataChannelMessage{}
|
||||
}
|
||||
}
|
||||
|
||||
func sendTestRTP(t *testing.T, track *webrtc.TrackLocalStaticRTP, payload []byte, received <-chan []byte) {
|
||||
t.Helper()
|
||||
for sequence := uint16(1); sequence <= 25; sequence++ {
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: 111,
|
||||
SequenceNumber: sequence,
|
||||
Timestamp: uint32(sequence) * 960,
|
||||
SSRC: 1234,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
if errWrite := track.WriteRTP(packet); errWrite != nil {
|
||||
t.Fatalf("write test RTP: %v", errWrite)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("relayed RTP payload = %v, want %v", got, payload)
|
||||
}
|
||||
return
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
t.Fatal("RTP packet was not relayed")
|
||||
}
|
||||
|
||||
func assertForwardingAfterRemoteConnected(t *testing.T, hook *logtest.Hook) {
|
||||
t.Helper()
|
||||
connectedIndex := -1
|
||||
forwardingIndex := -1
|
||||
for index, entry := range hook.AllEntries() {
|
||||
if entry.Message == "codex live WebRTC peer connected" && entry.Data["peer"] == "remote" && connectedIndex == -1 {
|
||||
connectedIndex = index
|
||||
}
|
||||
if entry.Message == "codex live remote media forwarding started" && forwardingIndex == -1 {
|
||||
forwardingIndex = index
|
||||
}
|
||||
}
|
||||
if connectedIndex == -1 || forwardingIndex <= connectedIndex {
|
||||
t.Fatalf("remote connected index=%d, forwarding index=%d", connectedIndex, forwardingIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func assertForwardingLog(t *testing.T, hook *logtest.Hook, connection, credential, authIndex, state string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Message == "codex live remote media forwarding started" &&
|
||||
entry.Data["connection"] == connection &&
|
||||
entry.Data["credential"] == credential &&
|
||||
entry.Data["auth_index"] == authIndex &&
|
||||
entry.Data["state"] == state {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("missing forwarding log for connection %q and credential %q", connection, credential)
|
||||
}
|
||||
|
||||
func assertSessionLog(t *testing.T, hook *logtest.Hook, message, reason, callID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Message == message && entry.Data["reason"] == reason && entry.Data["call_id"] == callID {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("missing session log message %q for reason %q and call %q", message, reason, callID)
|
||||
}
|
||||
|
||||
func assertPeerLog(t *testing.T, hook *logtest.Hook, message, peer, callID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Message == message && entry.Data["peer"] == peer && entry.Data["call_id"] == callID {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("missing log message %q for peer %q and call %q", message, peer, callID)
|
||||
}
|
||||
|
||||
func closeTestPeerConnection(t *testing.T, connection *webrtc.PeerConnection) {
|
||||
t.Helper()
|
||||
if errClose := connection.Close(); errClose != nil {
|
||||
t.Errorf("close test PeerConnection: %v", errClose)
|
||||
}
|
||||
}
|
||||
723
backend/internal/client/codex/live/sideband.go
Normal file
723
backend/internal/client/codex/live/sideband.go
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/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"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSidebandAPIBaseURL = "wss://api.openai.com/v1"
|
||||
sessionLifetime = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
callIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
sidebandUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(*http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type liveSession struct {
|
||||
callID string
|
||||
authID string
|
||||
model string
|
||||
ownerPrincipal string
|
||||
ownerProvider string
|
||||
clientSecretPrincipal string
|
||||
homeSelection *auth.HomeDispatchSelection
|
||||
media mediaRelaySession
|
||||
resources *liveSessionResources
|
||||
token uint64
|
||||
}
|
||||
|
||||
type liveSessionResources struct {
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
type storedSession struct {
|
||||
session liveSession
|
||||
claimed bool
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
lifetime time.Duration
|
||||
sessions map[string]*storedSession
|
||||
}
|
||||
|
||||
type sessionClaim int
|
||||
|
||||
const (
|
||||
sessionClaimMissing sessionClaim = iota
|
||||
sessionClaimBusy
|
||||
sessionClaimAcquired
|
||||
)
|
||||
|
||||
func newSessionStore() *sessionStore {
|
||||
return &sessionStore{
|
||||
lifetime: sessionLifetime,
|
||||
sessions: make(map[string]*storedSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) put(callID string, session liveSession) liveSession {
|
||||
if s == nil || !callIDPattern.MatchString(callID) {
|
||||
endLiveSession(session, "invalid_call_id")
|
||||
return liveSession{}
|
||||
}
|
||||
|
||||
if session.resources == nil {
|
||||
session.resources = &liveSessionResources{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.next++
|
||||
session.callID = callID
|
||||
session.token = s.next
|
||||
previous := s.sessions[callID]
|
||||
entry := &storedSession{session: session}
|
||||
entry.timer = time.AfterFunc(s.expiryDuration(), func() {
|
||||
s.expire(callID, session.token)
|
||||
})
|
||||
s.sessions[callID] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
if previous != nil {
|
||||
if previous.timer != nil {
|
||||
previous.timer.Stop()
|
||||
}
|
||||
if previous.session.resources != nil && previous.session.resources != session.resources {
|
||||
previous.session.resources.close()
|
||||
}
|
||||
if previous.session.media != nil && previous.session.media != session.media {
|
||||
if errClose := previous.session.media.CloseWithReason("session_replaced"); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close replaced session")
|
||||
}
|
||||
}
|
||||
if previous.session.homeSelection != session.homeSelection {
|
||||
endHomeSelection(previous.session, "session_replaced")
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *sessionStore) claim(callID string) (liveSession, sessionClaim) {
|
||||
if s == nil || !callIDPattern.MatchString(callID) {
|
||||
return liveSession{}, sessionClaimMissing
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry := s.sessions[callID]
|
||||
if entry == nil {
|
||||
return liveSession{}, sessionClaimMissing
|
||||
}
|
||||
if entry.claimed {
|
||||
return liveSession{}, sessionClaimBusy
|
||||
}
|
||||
entry.claimed = true
|
||||
if entry.timer != nil {
|
||||
entry.timer.Stop()
|
||||
entry.timer = nil
|
||||
}
|
||||
return entry.session, sessionClaimAcquired
|
||||
}
|
||||
|
||||
func (s *sessionStore) release(session liveSession) {
|
||||
if s == nil || session.callID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry := s.sessions[session.callID]
|
||||
if entry == nil || entry.session.token != session.token || !entry.claimed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.claimed = false
|
||||
entry.timer = time.AfterFunc(s.expiryDuration(), func() {
|
||||
s.expire(session.callID, session.token)
|
||||
})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStore) complete(session liveSession, reason string) {
|
||||
if s == nil || session.callID == "" {
|
||||
endLiveSession(session, reason)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry := s.sessions[session.callID]
|
||||
if entry == nil || entry.session.token != session.token {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(s.sessions, session.callID)
|
||||
if entry.timer != nil {
|
||||
entry.timer.Stop()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
endLiveSession(entry.session, reason)
|
||||
}
|
||||
|
||||
func (s *sessionStore) closeAll(reason string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
entries := make([]*storedSession, 0, len(s.sessions))
|
||||
for callID, entry := range s.sessions {
|
||||
delete(s.sessions, callID)
|
||||
if entry.timer != nil {
|
||||
entry.timer.Stop()
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, entry := range entries {
|
||||
endLiveSession(entry.session, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) expiryDuration() time.Duration {
|
||||
if s.lifetime > 0 {
|
||||
return s.lifetime
|
||||
}
|
||||
return sessionLifetime
|
||||
}
|
||||
|
||||
func (s *sessionStore) expire(callID string, token uint64) {
|
||||
s.mu.Lock()
|
||||
entry := s.sessions[callID]
|
||||
if entry == nil || entry.session.token != token || entry.claimed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(s.sessions, callID)
|
||||
s.mu.Unlock()
|
||||
endLiveSession(entry.session, "session_expired")
|
||||
}
|
||||
|
||||
func (s *sessionStore) peek(callID string) (liveSession, bool) {
|
||||
if s == nil {
|
||||
return liveSession{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry := s.sessions[callID]
|
||||
s.mu.Unlock()
|
||||
if entry == nil {
|
||||
return liveSession{}, false
|
||||
}
|
||||
return entry.session, true
|
||||
}
|
||||
|
||||
func endLiveSession(session liveSession, reason string) {
|
||||
if session.resources != nil {
|
||||
session.resources.close()
|
||||
}
|
||||
if session.media != nil {
|
||||
if errClose := session.media.CloseWithReason(reason); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live media: close stored session")
|
||||
}
|
||||
}
|
||||
endHomeSelection(session, reason)
|
||||
}
|
||||
|
||||
func endHomeSelection(session liveSession, reason string) {
|
||||
if session.homeSelection != nil {
|
||||
session.homeSelection.End(reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *liveSessionResources) add(closers ...func() error) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if !r.closed {
|
||||
r.closers = append(r.closers, closers...)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
closeSessionResources(closers)
|
||||
}
|
||||
|
||||
func (r *liveSessionResources) close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.closed = true
|
||||
closers := r.closers
|
||||
r.closers = nil
|
||||
r.mu.Unlock()
|
||||
closeSessionResources(closers)
|
||||
}
|
||||
|
||||
func closeSessionResources(closers []func() error) {
|
||||
for _, closer := range closers {
|
||||
if closer == nil {
|
||||
continue
|
||||
}
|
||||
if errClose := closer(); errClose != nil && !isNormalWebsocketClose(errClose) {
|
||||
log.WithError(errClose).Debug("codex live: close session resource")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sidebandStyle int
|
||||
|
||||
const (
|
||||
sidebandFrameless sidebandStyle = iota
|
||||
sidebandRealtimeCalls
|
||||
sidebandRealtimeQuery
|
||||
)
|
||||
|
||||
// HandleSideband relays live session sideband WebSocket frames bidirectionally.
|
||||
func (h *Handler) HandleSideband(c *gin.Context) {
|
||||
if h == nil || h.authManager == nil || h.sessions == nil {
|
||||
writeLiveError(c, http.StatusServiceUnavailable, "Codex live sideband unavailable")
|
||||
return
|
||||
}
|
||||
runtimeConfig := h.currentConfig()
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.Header("Upgrade", "websocket")
|
||||
writeLiveError(c, http.StatusUpgradeRequired, "WebSocket upgrade required")
|
||||
return
|
||||
}
|
||||
|
||||
style, callID, ok := sidebandTarget(c)
|
||||
if !ok {
|
||||
writeLiveError(c, http.StatusBadRequest, "Invalid Codex live call ID")
|
||||
return
|
||||
}
|
||||
session, claim := h.sessions.claim(callID)
|
||||
switch claim {
|
||||
case sessionClaimBusy:
|
||||
writeLiveError(c, http.StatusConflict, "Codex live session already joining")
|
||||
return
|
||||
case sessionClaimAcquired:
|
||||
default:
|
||||
writeLiveError(c, http.StatusNotFound, "Codex live session not found")
|
||||
return
|
||||
}
|
||||
if principal, hasClientSecret := c.Get(ClientSecretPrincipalContextKey); hasClientSecret {
|
||||
principalValue, _ := principal.(string)
|
||||
if session.clientSecretPrincipal == "" || principalValue != session.clientSecretPrincipal {
|
||||
h.sessions.release(session)
|
||||
writeRealtimeError(c, http.StatusForbidden, "Realtime client secret is not valid for this call", "invalid_request_error", "realtime_client_secret_scope_mismatch")
|
||||
return
|
||||
}
|
||||
} else if ownerPrincipal, ownerProvider := requestOwner(c); session.ownerPrincipal != "" && (ownerPrincipal != session.ownerPrincipal || ownerProvider != session.ownerProvider) {
|
||||
h.sessions.release(session)
|
||||
writeRealtimeError(c, http.StatusForbidden, "Realtime call belongs to another API principal", "invalid_request_error", "realtime_call_scope_mismatch")
|
||||
return
|
||||
}
|
||||
consumeSession := false
|
||||
defer func() {
|
||||
if consumeSession {
|
||||
h.sessions.complete(session, "session_closed")
|
||||
return
|
||||
}
|
||||
h.sessions.release(session)
|
||||
}()
|
||||
|
||||
ctx := context.WithValue(c.Request.Context(), "gin", c)
|
||||
ctx = coreexecutor.WithDownstreamWebsocket(ctx)
|
||||
var selection *auth.HomeDispatchSelection
|
||||
var selected *auth.Auth
|
||||
var errSelect error
|
||||
if session.homeSelection != nil {
|
||||
if !session.homeSelection.Active() {
|
||||
consumeSession = true
|
||||
writeLiveError(c, http.StatusServiceUnavailable, "Codex live Home selection unavailable")
|
||||
return
|
||||
}
|
||||
selection = session.homeSelection
|
||||
selected = selection.CloneAuth()
|
||||
} else {
|
||||
selectionOpts := coreexecutor.Options{
|
||||
Headers: liveSelectionHeaders(c),
|
||||
Metadata: map[string]any{
|
||||
coreexecutor.PinnedAuthMetadataKey: session.authID,
|
||||
coreexecutor.ExecutionSessionMetadataKey: callID,
|
||||
},
|
||||
}
|
||||
selection, selected, errSelect = h.selectOAuth(ctx, session.model, selectionOpts)
|
||||
}
|
||||
if errSelect != nil {
|
||||
writeSelectionError(c, errSelect)
|
||||
return
|
||||
}
|
||||
if selected == nil {
|
||||
writeLiveError(c, http.StatusServiceUnavailable, "Codex auth unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
if selection != nil {
|
||||
attemptCtx, releaseAttempt, errAttempt := selection.AttemptContext(ctx)
|
||||
if errAttempt != nil {
|
||||
consumeSession = true
|
||||
writeLiveError(c, http.StatusServiceUnavailable, errAttempt.Error())
|
||||
return
|
||||
}
|
||||
ctx = attemptCtx
|
||||
defer releaseAttempt()
|
||||
}
|
||||
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
||||
|
||||
upstreamURL := buildSidebandURL(h.sidebandAPIBaseURL, style, callID)
|
||||
upstreamHTTPURL := websocketHTTPURL(upstreamURL)
|
||||
dialUpstream := func(current *auth.Auth) (*websocket.Conn, *http.Response, error) {
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, upstreamHTTPURL, nil)
|
||||
if errRequest != nil {
|
||||
return nil, nil, errRequest
|
||||
}
|
||||
req.Header = protocolHeaders(c.Request.Header)
|
||||
setAccountHeader(req.Header, current)
|
||||
if errPrepare := h.authManager.PrepareHttpRequest(ctx, current, req); errPrepare != nil {
|
||||
return nil, nil, errPrepare
|
||||
}
|
||||
authType, authValue := current.AccountInfo()
|
||||
helps.RecordAPIWebsocketRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
|
||||
URL: upstreamURL,
|
||||
Method: "WEBSOCKET",
|
||||
Headers: headersForLogging(req.Header),
|
||||
Provider: "codex",
|
||||
AuthID: current.ID,
|
||||
AuthLabel: current.Label,
|
||||
AuthType: authType,
|
||||
AuthValue: authValue,
|
||||
})
|
||||
dialer := newProxyAwareSidebandDialer(runtimeConfig, current)
|
||||
dialer.Subprotocols = websocket.Subprotocols(c.Request)
|
||||
return dialer.DialContext(ctx, upstreamURL, req.Header)
|
||||
}
|
||||
|
||||
upstream, handshakeResponse, errDial := dialUpstream(selected)
|
||||
if errDial != nil && selection != nil && handshakeResponse != nil && handshakeResponse.StatusCode == http.StatusUnauthorized {
|
||||
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
||||
helps.RecordAPIWebsocketHandshake(ctx, runtimeConfig, handshakeResponse.StatusCode, callResponseHeaders(handshakeResponse.Header))
|
||||
if handshakeResponse.Body != nil {
|
||||
if errClose := handshakeResponse.Body.Close(); errClose != nil {
|
||||
log.Errorf("codex live sideband: close unauthorized handshake body error: %v", errClose)
|
||||
}
|
||||
}
|
||||
refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
|
||||
if errRefresh != nil {
|
||||
writeSelectionError(c, errRefresh)
|
||||
return
|
||||
}
|
||||
if !didRefresh || refreshed == nil {
|
||||
writeLiveError(c, http.StatusUnauthorized, "Codex credential unauthorized")
|
||||
return
|
||||
}
|
||||
selected = refreshed
|
||||
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
||||
upstream, handshakeResponse, errDial = dialUpstream(selected)
|
||||
if errDial != nil && handshakeResponse != nil && handshakeResponse.StatusCode == http.StatusUnauthorized {
|
||||
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
||||
}
|
||||
}
|
||||
if errDial != nil {
|
||||
handleSidebandDialError(c, ctx, runtimeConfig, handshakeResponse, errDial)
|
||||
return
|
||||
}
|
||||
if handshakeResponse != nil {
|
||||
helps.RecordAPIWebsocketHandshake(ctx, runtimeConfig, handshakeResponse.StatusCode, callResponseHeaders(handshakeResponse.Header))
|
||||
if handshakeResponse.Body != nil {
|
||||
if errClose := handshakeResponse.Body.Close(); errClose != nil {
|
||||
log.Errorf("codex live sideband: close handshake response body error: %v", errClose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeUpstream := websocketCloseFunc("upstream", upstream)
|
||||
if selection != nil {
|
||||
if errBind := selection.Bind(closeUpstream); errBind != nil {
|
||||
consumeSession = true
|
||||
writeLiveError(c, http.StatusServiceUnavailable, errBind.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
defer func() { _ = closeUpstream() }()
|
||||
}
|
||||
|
||||
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)
|
||||
if selection != nil {
|
||||
if errBind := selection.Bind(closeDownstream); errBind != nil {
|
||||
consumeSession = true
|
||||
return
|
||||
}
|
||||
} else {
|
||||
defer func() { _ = closeDownstream() }()
|
||||
}
|
||||
if session.resources != nil {
|
||||
session.resources.add(closeUpstream, closeDownstream)
|
||||
}
|
||||
consumeSession = true
|
||||
|
||||
if errRelay := relayWebsockets(downstream, upstream); errRelay != nil && !isNormalWebsocketClose(errRelay) {
|
||||
helps.RecordAPIWebsocketError(ctx, runtimeConfig, "relay", errRelay)
|
||||
log.WithError(errRelay).Debug("codex live sideband relay closed")
|
||||
}
|
||||
}
|
||||
|
||||
func sidebandTarget(c *gin.Context) (sidebandStyle, string, bool) {
|
||||
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||
return sidebandFrameless, "", false
|
||||
}
|
||||
if callID := strings.TrimSpace(c.Param("call_id")); callID != "" {
|
||||
style := sidebandFrameless
|
||||
if strings.Contains(c.Request.URL.Path, "/realtime/calls/") {
|
||||
style = sidebandRealtimeCalls
|
||||
}
|
||||
return style, callID, callIDPattern.MatchString(callID)
|
||||
}
|
||||
callID := strings.TrimSpace(c.Query("call_id"))
|
||||
return sidebandRealtimeQuery, callID, callIDPattern.MatchString(callID)
|
||||
}
|
||||
|
||||
func buildSidebandURL(baseURL string, style sidebandStyle, callID string) string {
|
||||
root := strings.TrimRight(baseURL, "/")
|
||||
switch style {
|
||||
case sidebandRealtimeCalls:
|
||||
return root + "/realtime/calls/" + callID
|
||||
case sidebandRealtimeQuery:
|
||||
return root + "/realtime?intent=quicksilver&call_id=" + url.QueryEscape(callID)
|
||||
default:
|
||||
return root + "/live/" + callID
|
||||
}
|
||||
}
|
||||
|
||||
func websocketHTTPURL(rawURL string) string {
|
||||
parsed, errParse := url.Parse(rawURL)
|
||||
if errParse != nil {
|
||||
return rawURL
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "ws":
|
||||
parsed.Scheme = "http"
|
||||
case "wss":
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func callIDFromLocation(location string) string {
|
||||
location = strings.TrimSpace(location)
|
||||
if callIDPattern.MatchString(location) {
|
||||
return location
|
||||
}
|
||||
parsed, errParse := url.Parse(location)
|
||||
if errParse != nil {
|
||||
return ""
|
||||
}
|
||||
if callID := strings.TrimSpace(parsed.Query().Get("call_id")); callIDPattern.MatchString(callID) {
|
||||
return callID
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
callID := parts[len(parts)-1]
|
||||
previous := parts[len(parts)-2]
|
||||
if !callIDPattern.MatchString(callID) || (previous != "live" && previous != "calls") {
|
||||
return ""
|
||||
}
|
||||
return callID
|
||||
}
|
||||
|
||||
func handleSidebandDialError(c *gin.Context, ctx context.Context, cfg *config.Config, response *http.Response, errDial error) {
|
||||
status := clienterror.HTTPStatusFromErrorOr(errDial, http.StatusBadGateway)
|
||||
if response != nil {
|
||||
if response.StatusCode > 0 {
|
||||
status = response.StatusCode
|
||||
}
|
||||
copyRealtimeHandshakeHeaders(c.Writer.Header(), response.Header)
|
||||
helps.RecordAPIWebsocketHandshake(ctx, cfg, response.StatusCode, callResponseHeaders(response.Header))
|
||||
if response.Body != nil {
|
||||
if errClose := response.Body.Close(); errClose != nil {
|
||||
log.Errorf("codex live sideband: close rejected handshake body error: %v", errClose)
|
||||
}
|
||||
}
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, cfg, "dial", errDial)
|
||||
writeLiveError(c, status, "Codex live sideband upstream unavailable")
|
||||
}
|
||||
|
||||
func websocketCloseFunc(name string, conn *websocket.Conn) func() error {
|
||||
var once sync.Once
|
||||
var closeErr error
|
||||
return func() error {
|
||||
once.Do(func() {
|
||||
closeErr = conn.Close()
|
||||
if closeErr != nil && !isNormalWebsocketClose(closeErr) {
|
||||
log.Debugf("codex live sideband: close %s websocket error: %v", name, closeErr)
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
|
||||
func relayWebsockets(downstream, upstream *websocket.Conn) error {
|
||||
results := make(chan error, 2)
|
||||
go func() { results <- copyWebsocket(upstream, downstream) }()
|
||||
go func() { results <- copyWebsocket(downstream, upstream) }()
|
||||
|
||||
firstErr := <-results
|
||||
closeCode, closeReason := websocketCloseDetails(firstErr)
|
||||
payload := websocket.FormatCloseMessage(closeCode, closeReason)
|
||||
_ = downstream.WriteControl(websocket.CloseMessage, payload, time.Time{})
|
||||
_ = upstream.WriteControl(websocket.CloseMessage, payload, time.Time{})
|
||||
_ = downstream.Close()
|
||||
_ = upstream.Close()
|
||||
<-results
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func copyWebsocket(destination, source *websocket.Conn) error {
|
||||
for {
|
||||
messageType, reader, errReader := source.NextReader()
|
||||
if errReader != nil {
|
||||
return errReader
|
||||
}
|
||||
writer, errWriter := destination.NextWriter(messageType)
|
||||
if errWriter != nil {
|
||||
return errWriter
|
||||
}
|
||||
_, errCopy := io.Copy(writer, reader)
|
||||
errClose := writer.Close()
|
||||
if errCopy != nil {
|
||||
return errCopy
|
||||
}
|
||||
if errClose != nil {
|
||||
return errClose
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func websocketCloseDetails(err error) (int, string) {
|
||||
var closeErr *websocket.CloseError
|
||||
if errors.As(err, &closeErr) {
|
||||
switch closeErr.Code {
|
||||
case websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure, websocket.CloseTLSHandshake:
|
||||
return websocket.CloseNormalClosure, ""
|
||||
default:
|
||||
return closeErr.Code, closeErr.Text
|
||||
}
|
||||
}
|
||||
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
return websocket.CloseNormalClosure, ""
|
||||
}
|
||||
return websocket.CloseInternalServerErr, "relay closed"
|
||||
}
|
||||
|
||||
func isNormalWebsocketClose(err error) bool {
|
||||
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
return true
|
||||
}
|
||||
return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived)
|
||||
}
|
||||
|
||||
func newProxyAwareSidebandDialer(cfg *config.Config, selected *auth.Auth) *websocket.Dialer {
|
||||
return newSidebandDialer(proxyURLForAuth(cfg, selected))
|
||||
}
|
||||
|
||||
func proxyURLForAuth(cfg *config.Config, selected *auth.Auth) string {
|
||||
if selected != nil && strings.TrimSpace(selected.ProxyURL) != "" {
|
||||
return strings.TrimSpace(selected.ProxyURL)
|
||||
}
|
||||
if cfg != nil {
|
||||
return strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newSidebandDialer(proxyURL string) *websocket.Dialer {
|
||||
dialer := &websocket.Dialer{Proxy: http.ProxyFromEnvironment}
|
||||
if strings.TrimSpace(proxyURL) == "" {
|
||||
return dialer
|
||||
}
|
||||
|
||||
setting, errParse := proxyutil.Parse(proxyURL)
|
||||
if errParse != nil {
|
||||
log.Errorf("codex live sideband: %v", errParse)
|
||||
return dialer
|
||||
}
|
||||
switch setting.Mode {
|
||||
case proxyutil.ModeDirect:
|
||||
dialer.Proxy = nil
|
||||
return dialer
|
||||
case proxyutil.ModeProxy:
|
||||
default:
|
||||
return dialer
|
||||
}
|
||||
|
||||
switch setting.URL.Scheme {
|
||||
case "socks5", "socks5h":
|
||||
var proxyAuth *xproxy.Auth
|
||||
if setting.URL.User != nil {
|
||||
username := setting.URL.User.Username()
|
||||
password, _ := setting.URL.User.Password()
|
||||
proxyAuth = &xproxy.Auth{User: username, Password: password}
|
||||
}
|
||||
socksDialer, errSOCKS5 := xproxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, xproxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.Errorf("codex live sideband: create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||
return dialer
|
||||
}
|
||||
dialer.Proxy = nil
|
||||
if contextDialer, ok := socksDialer.(xproxy.ContextDialer); ok {
|
||||
dialer.NetDialContext = contextDialer.DialContext
|
||||
} else {
|
||||
dialer.NetDialContext = func(_ context.Context, network, address string) (net.Conn, error) {
|
||||
return socksDialer.Dial(network, address)
|
||||
}
|
||||
}
|
||||
case "http", "https":
|
||||
dialer.Proxy = http.ProxyURL(setting.URL)
|
||||
default:
|
||||
log.Errorf("codex live sideband: unsupported proxy scheme: %s", setting.URL.Scheme)
|
||||
}
|
||||
return dialer
|
||||
}
|
||||
548
backend/internal/client/codex/live/tcp_proxy.go
Normal file
548
backend/internal/client/codex/live/tcp_proxy.go
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/stun/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
maxUpstreamICECandidates = 64
|
||||
maxProxiedTCPCandidates = 16
|
||||
maxUnauthenticatedTCPConns = 4
|
||||
maxInitialSTUNFrameSize = 4096
|
||||
stunMessageHeaderSize = 20
|
||||
)
|
||||
|
||||
var nonRoutableProxyTargetPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("::/96"),
|
||||
netip.MustParsePrefix("::ffff:0:0:0/96"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001::/23"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("3fff::/20"),
|
||||
netip.MustParsePrefix("5f00::/16"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("fec0::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
type iceCredentials struct {
|
||||
ufrag string
|
||||
password string
|
||||
}
|
||||
|
||||
type tcpCandidateTunnel struct {
|
||||
listener net.Listener
|
||||
target netip.AddrPort
|
||||
dialer proxy.ContextDialer
|
||||
expectedUser string
|
||||
remotePassword string
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
claimed bool
|
||||
connections map[net.Conn]struct{}
|
||||
validationSlots chan struct{}
|
||||
onForwardingStarted func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type tcpCandidatePlan struct {
|
||||
mediaIndex int
|
||||
attributeIndex int
|
||||
fields []string
|
||||
target netip.AddrPort
|
||||
}
|
||||
|
||||
func prepareProxiedUpstreamAnswer(answer, localOffer string, dialer proxy.ContextDialer) (string, []*tcpCandidateTunnel, error) {
|
||||
if dialer == nil {
|
||||
return "", nil, errors.New("Codex live TCP proxy dialer is unavailable")
|
||||
}
|
||||
var remoteDescription sdp.SessionDescription
|
||||
if errUnmarshal := remoteDescription.UnmarshalString(answer); errUnmarshal != nil {
|
||||
return "", nil, fmt.Errorf("parse upstream WebRTC answer for TCP proxy: %w", errUnmarshal)
|
||||
}
|
||||
var localDescription sdp.SessionDescription
|
||||
if errUnmarshal := localDescription.UnmarshalString(localOffer); errUnmarshal != nil {
|
||||
return "", nil, fmt.Errorf("parse upstream WebRTC offer for TCP proxy: %w", errUnmarshal)
|
||||
}
|
||||
remoteCredentials, errCredentials := bundledICECredentials(&remoteDescription)
|
||||
if errCredentials != nil {
|
||||
return "", nil, fmt.Errorf("read upstream WebRTC answer ICE credentials: %w", errCredentials)
|
||||
}
|
||||
localCredentials, errCredentials := bundledICECredentials(&localDescription)
|
||||
if errCredentials != nil {
|
||||
return "", nil, fmt.Errorf("read upstream WebRTC offer ICE credentials: %w", errCredentials)
|
||||
}
|
||||
|
||||
plans := make([]tcpCandidatePlan, 0, 4)
|
||||
candidateCount := 0
|
||||
for mediaIndex, media := range remoteDescription.MediaDescriptions {
|
||||
if media == nil {
|
||||
continue
|
||||
}
|
||||
filtered := make([]sdp.Attribute, 0, len(media.Attributes))
|
||||
for attributeIndex := range media.Attributes {
|
||||
attribute := media.Attributes[attributeIndex]
|
||||
if !attribute.IsICECandidate() {
|
||||
filtered = append(filtered, attribute)
|
||||
continue
|
||||
}
|
||||
candidateCount++
|
||||
if candidateCount > maxUpstreamICECandidates {
|
||||
return "", nil, fmt.Errorf("upstream WebRTC answer exceeds the %d candidate limit", maxUpstreamICECandidates)
|
||||
}
|
||||
plan, keep, errCandidate := proxiedTCPCandidatePlan(attribute.Value)
|
||||
if errCandidate != nil {
|
||||
return "", nil, errCandidate
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
if len(plans) >= maxProxiedTCPCandidates {
|
||||
return "", nil, fmt.Errorf("upstream WebRTC answer exceeds the %d TCP candidate proxy limit", maxProxiedTCPCandidates)
|
||||
}
|
||||
plan.mediaIndex = mediaIndex
|
||||
plan.attributeIndex = len(filtered)
|
||||
filtered = append(filtered, attribute)
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
media.Attributes = filtered
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return "", nil, errors.New("upstream WebRTC answer has no supported public TCP passive candidate on port 443")
|
||||
}
|
||||
|
||||
expectedUser := remoteCredentials.ufrag + ":" + localCredentials.ufrag
|
||||
tunnels := make([]*tcpCandidateTunnel, 0, len(plans))
|
||||
closeTunnels := func() {
|
||||
for _, tunnel := range tunnels {
|
||||
if errClose := tunnel.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live TCP proxy: close candidate tunnel after setup error")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, plan := range plans {
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(plan.target, dialer, expectedUser, remoteCredentials.password)
|
||||
if errTunnel != nil {
|
||||
closeTunnels()
|
||||
return "", nil, errTunnel
|
||||
}
|
||||
tunnels = append(tunnels, tunnel)
|
||||
listenerAddress, ok := tunnel.listener.Addr().(*net.TCPAddr)
|
||||
if !ok || listenerAddress.IP == nil {
|
||||
closeTunnels()
|
||||
return "", nil, errors.New("Codex live TCP proxy listener returned an invalid address")
|
||||
}
|
||||
fields := append([]string(nil), plan.fields...)
|
||||
fields[4] = listenerAddress.IP.String()
|
||||
fields[5] = strconv.Itoa(listenerAddress.Port)
|
||||
remoteDescription.MediaDescriptions[plan.mediaIndex].Attributes[plan.attributeIndex].Value = strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
rewritten, errMarshal := remoteDescription.Marshal()
|
||||
if errMarshal != nil {
|
||||
closeTunnels()
|
||||
return "", nil, fmt.Errorf("marshal proxied upstream WebRTC answer: %w", errMarshal)
|
||||
}
|
||||
return string(rewritten), tunnels, nil
|
||||
}
|
||||
|
||||
func proxiedTCPCandidatePlan(rawCandidate string) (tcpCandidatePlan, bool, error) {
|
||||
trimmed := strings.TrimSpace(rawCandidate)
|
||||
candidate, errCandidate := ice.UnmarshalCandidate(trimmed)
|
||||
if errCandidate != nil {
|
||||
return tcpCandidatePlan{}, false, fmt.Errorf("parse upstream WebRTC candidate: %w", errCandidate)
|
||||
}
|
||||
if candidate.NetworkType() != ice.NetworkTypeTCP4 && candidate.NetworkType() != ice.NetworkTypeTCP6 {
|
||||
return tcpCandidatePlan{}, false, nil
|
||||
}
|
||||
if candidate.TCPType() != ice.TCPTypePassive {
|
||||
return tcpCandidatePlan{}, false, nil
|
||||
}
|
||||
if candidate.Component() != uint16(ice.ComponentRTP) || candidate.Type() != ice.CandidateTypeHost {
|
||||
return tcpCandidatePlan{}, false, nil
|
||||
}
|
||||
if candidate.Port() != 443 {
|
||||
return tcpCandidatePlan{}, false, fmt.Errorf("upstream WebRTC TCP proxy candidate uses disallowed port %d", candidate.Port())
|
||||
}
|
||||
address, errAddress := netip.ParseAddr(candidate.Address())
|
||||
if errAddress != nil {
|
||||
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate address must be an IP")
|
||||
}
|
||||
address = address.Unmap()
|
||||
if !isPublicProxyTarget(address) {
|
||||
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate address must be globally routable")
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) < 8 {
|
||||
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate is malformed")
|
||||
}
|
||||
return tcpCandidatePlan{
|
||||
fields: fields,
|
||||
target: netip.AddrPortFrom(address, uint16(candidate.Port())),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func isPublicProxyTarget(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() || address.IsUnspecified() || address.IsLoopback() ||
|
||||
address.IsPrivate() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range nonRoutableProxyTargetPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bundledICECredentials(description *sdp.SessionDescription) (iceCredentials, error) {
|
||||
if description == nil {
|
||||
return iceCredentials{}, errors.New("SDP is unavailable")
|
||||
}
|
||||
sessionUfrag, _ := description.Attribute("ice-ufrag")
|
||||
sessionPassword, _ := description.Attribute("ice-pwd")
|
||||
var selected iceCredentials
|
||||
for _, media := range description.MediaDescriptions {
|
||||
if media == nil {
|
||||
continue
|
||||
}
|
||||
ufrag := sessionUfrag
|
||||
if mediaUfrag, ok := media.Attribute("ice-ufrag"); ok {
|
||||
ufrag = mediaUfrag
|
||||
}
|
||||
password := sessionPassword
|
||||
if mediaPassword, ok := media.Attribute("ice-pwd"); ok {
|
||||
password = mediaPassword
|
||||
}
|
||||
ufrag = strings.TrimSpace(ufrag)
|
||||
password = strings.TrimSpace(password)
|
||||
if ufrag == "" && password == "" {
|
||||
continue
|
||||
}
|
||||
if ufrag == "" || password == "" {
|
||||
return iceCredentials{}, errors.New("SDP contains incomplete ICE credentials")
|
||||
}
|
||||
current := iceCredentials{ufrag: ufrag, password: password}
|
||||
if selected.ufrag == "" {
|
||||
selected = current
|
||||
continue
|
||||
}
|
||||
if selected != current {
|
||||
return iceCredentials{}, errors.New("SDP contains inconsistent bundled ICE credentials")
|
||||
}
|
||||
}
|
||||
if selected.ufrag == "" {
|
||||
selected = iceCredentials{ufrag: strings.TrimSpace(sessionUfrag), password: strings.TrimSpace(sessionPassword)}
|
||||
}
|
||||
if selected.ufrag == "" || selected.password == "" {
|
||||
return iceCredentials{}, errors.New("SDP is missing ICE credentials")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func closeCandidateTunnels(tunnels []*tcpCandidateTunnel) error {
|
||||
var closeErrors []error
|
||||
for _, tunnel := range tunnels {
|
||||
if errClose := tunnel.Close(); errClose != nil {
|
||||
closeErrors = append(closeErrors, errClose)
|
||||
}
|
||||
}
|
||||
return errors.Join(closeErrors...)
|
||||
}
|
||||
|
||||
func newTCPCandidateTunnel(target netip.AddrPort, dialer proxy.ContextDialer, expectedUser, remotePassword string) (*tcpCandidateTunnel, error) {
|
||||
if !isPublicProxyTarget(target.Addr()) || target.Port() != 443 {
|
||||
return nil, errors.New("Codex live TCP proxy target is not allowed")
|
||||
}
|
||||
if dialer == nil || strings.TrimSpace(expectedUser) == "" || strings.TrimSpace(remotePassword) == "" {
|
||||
return nil, errors.New("Codex live TCP proxy tunnel configuration is incomplete")
|
||||
}
|
||||
network := "tcp4"
|
||||
listenAddress := "127.0.0.1:0"
|
||||
if target.Addr().Is6() {
|
||||
network = "tcp6"
|
||||
listenAddress = "[::1]:0"
|
||||
}
|
||||
listener, errListen := net.Listen(network, listenAddress)
|
||||
if errListen != nil {
|
||||
return nil, fmt.Errorf("listen for Codex live TCP proxy candidate: %w", errListen)
|
||||
}
|
||||
tunnelContext, cancelTunnel := context.WithCancel(context.Background())
|
||||
tunnel := &tcpCandidateTunnel{
|
||||
listener: listener,
|
||||
target: target,
|
||||
dialer: dialer,
|
||||
expectedUser: expectedUser,
|
||||
remotePassword: remotePassword,
|
||||
connections: make(map[net.Conn]struct{}),
|
||||
validationSlots: make(chan struct{}, maxUnauthenticatedTCPConns),
|
||||
ctx: tunnelContext,
|
||||
cancel: cancelTunnel,
|
||||
}
|
||||
go tunnel.accept()
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) accept() {
|
||||
for {
|
||||
connection, errAccept := t.listener.Accept()
|
||||
if errAccept != nil {
|
||||
if !errors.Is(errAccept, net.ErrClosed) {
|
||||
log.WithError(errAccept).Warn("codex live TCP proxy: accept candidate connection failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !t.trackConnection(connection) {
|
||||
if errClose := connection.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live TCP proxy: close connection after tunnel shutdown")
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case t.validationSlots <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-t.validationSlots }()
|
||||
t.handleConnection(connection)
|
||||
}()
|
||||
default:
|
||||
t.untrackAndClose(connection)
|
||||
log.Warn("codex live TCP proxy: rejected excess unauthenticated candidate connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) handleConnection(client net.Conn) {
|
||||
firstFrame, errValidate := readValidatedICEBindingFrame(client, t.expectedUser, t.remotePassword)
|
||||
if errValidate != nil {
|
||||
t.untrackAndClose(client)
|
||||
log.WithError(errValidate).Warn("codex live TCP proxy: rejected unauthenticated candidate connection")
|
||||
return
|
||||
}
|
||||
if !t.claim() {
|
||||
t.untrackAndClose(client)
|
||||
return
|
||||
}
|
||||
if errClose := t.listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.WithError(errClose).Debug("codex live TCP proxy: close claimed candidate listener")
|
||||
}
|
||||
upstream, errDial := t.dialer.DialContext(t.ctx, "tcp", t.target.String())
|
||||
if errDial != nil {
|
||||
t.untrackAndClose(client)
|
||||
log.WithError(errDial).Warn("codex live TCP proxy: connect fixed upstream candidate failed")
|
||||
return
|
||||
}
|
||||
if !t.trackConnection(upstream) {
|
||||
if errClose := upstream.Close(); errClose != nil {
|
||||
log.WithError(errClose).Debug("codex live TCP proxy: close upstream after tunnel shutdown")
|
||||
}
|
||||
t.untrackAndClose(client)
|
||||
return
|
||||
}
|
||||
if errWrite := writeAll(upstream, firstFrame); errWrite != nil {
|
||||
t.untrackAndClose(upstream)
|
||||
t.untrackAndClose(client)
|
||||
log.WithError(errWrite).Warn("codex live TCP proxy: forward authenticated ICE frame failed")
|
||||
return
|
||||
}
|
||||
t.notifyForwardingStarted()
|
||||
|
||||
copyDone := make(chan struct{}, 2)
|
||||
copyConnection := func(destination, source net.Conn) {
|
||||
_, _ = io.Copy(destination, source)
|
||||
copyDone <- struct{}{}
|
||||
}
|
||||
go copyConnection(upstream, client)
|
||||
go copyConnection(client, upstream)
|
||||
<-copyDone
|
||||
t.untrackAndClose(upstream)
|
||||
t.untrackAndClose(client)
|
||||
<-copyDone
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) setForwardingStartedHandler(handler func()) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.onForwardingStarted = handler
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) notifyForwardingStarted() {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
handler := t.onForwardingStarted
|
||||
t.mu.Unlock()
|
||||
if handler != nil {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) trackConnection(connection net.Conn) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return false
|
||||
}
|
||||
t.connections[connection] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) untrackAndClose(connection net.Conn) {
|
||||
if connection == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
delete(t.connections, connection)
|
||||
t.mu.Unlock()
|
||||
if errClose := connection.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.WithError(errClose).Debug("codex live TCP proxy: close tunnel connection")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) claim() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed || t.claimed {
|
||||
return false
|
||||
}
|
||||
t.claimed = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *tcpCandidateTunnel) Close() error {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
cancel := t.cancel
|
||||
connections := make([]net.Conn, 0, len(t.connections))
|
||||
for connection := range t.connections {
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
t.connections = make(map[net.Conn]struct{})
|
||||
t.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
var closeErrors []error
|
||||
if t.listener != nil {
|
||||
if errClose := t.listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
closeErrors = append(closeErrors, errClose)
|
||||
}
|
||||
}
|
||||
for _, connection := range connections {
|
||||
if errClose := connection.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
closeErrors = append(closeErrors, errClose)
|
||||
}
|
||||
}
|
||||
return errors.Join(closeErrors...)
|
||||
}
|
||||
|
||||
func readValidatedICEBindingFrame(connection io.Reader, expectedUser, remotePassword string) ([]byte, error) {
|
||||
var header [2]byte
|
||||
if _, errRead := io.ReadFull(connection, header[:]); errRead != nil {
|
||||
return nil, fmt.Errorf("read ICE-TCP frame header: %w", errRead)
|
||||
}
|
||||
frameSize := int(binary.BigEndian.Uint16(header[:]))
|
||||
if frameSize < stunMessageHeaderSize || frameSize > maxInitialSTUNFrameSize {
|
||||
return nil, fmt.Errorf("invalid initial ICE-TCP STUN frame size %d", frameSize)
|
||||
}
|
||||
payload := make([]byte, frameSize)
|
||||
if _, errRead := io.ReadFull(connection, payload); errRead != nil {
|
||||
return nil, fmt.Errorf("read ICE-TCP STUN frame: %w", errRead)
|
||||
}
|
||||
message := stun.NewWithOptions(stun.WithStrict(true))
|
||||
if errDecode := stun.Decode(payload, message); errDecode != nil {
|
||||
return nil, fmt.Errorf("decode initial ICE-TCP STUN message: %w", errDecode)
|
||||
}
|
||||
if len(payload) != stunMessageHeaderSize+int(message.Length) {
|
||||
return nil, errors.New("initial ICE-TCP STUN message contains trailing data")
|
||||
}
|
||||
if message.Type != stun.BindingRequest {
|
||||
return nil, fmt.Errorf("initial ICE-TCP STUN message has unexpected type %s", message.Type)
|
||||
}
|
||||
var username stun.Username
|
||||
if errUsername := username.GetFrom(message); errUsername != nil {
|
||||
return nil, fmt.Errorf("read initial ICE-TCP STUN username: %w", errUsername)
|
||||
}
|
||||
if string(username) != expectedUser {
|
||||
return nil, errors.New("initial ICE-TCP STUN username does not match the media session")
|
||||
}
|
||||
if errIntegrity := stun.NewShortTermIntegrity(remotePassword).Check(message); errIntegrity != nil {
|
||||
return nil, fmt.Errorf("verify initial ICE-TCP STUN integrity: %w", errIntegrity)
|
||||
}
|
||||
if errFingerprint := stun.Fingerprint.Check(message); errFingerprint != nil {
|
||||
return nil, fmt.Errorf("verify initial ICE-TCP STUN fingerprint: %w", errFingerprint)
|
||||
}
|
||||
frame := make([]byte, len(header)+len(payload))
|
||||
copy(frame, header[:])
|
||||
copy(frame[len(header):], payload)
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func writeAll(writer io.Writer, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
written, errWrite := writer.Write(data)
|
||||
if errWrite != nil {
|
||||
return errWrite
|
||||
}
|
||||
if written <= 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
data = data[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func proxyScheme(rawProxyURL string) string {
|
||||
trimmed := strings.TrimSpace(rawProxyURL)
|
||||
if index := strings.Index(trimmed, "://"); index > 0 {
|
||||
return strings.ToLower(trimmed[:index])
|
||||
}
|
||||
return "proxy"
|
||||
}
|
||||
651
backend/internal/client/codex/live/tcp_proxy_test.go
Normal file
651
backend/internal/client/codex/live/tcp_proxy_test.go
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
type recordedProxyDial struct {
|
||||
address string
|
||||
connection net.Conn
|
||||
}
|
||||
|
||||
type recordingProxyDialer struct {
|
||||
mu sync.Mutex
|
||||
dials chan recordedProxyDial
|
||||
err error
|
||||
}
|
||||
|
||||
type blockingContextDialer struct {
|
||||
started chan struct{}
|
||||
canceled chan struct{}
|
||||
}
|
||||
|
||||
type closedUpstreamDialer struct{}
|
||||
|
||||
func (*closedUpstreamDialer) DialContext(context.Context, string, string) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
_ = server.Close()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *blockingContextDialer) DialContext(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
close(d.started)
|
||||
<-ctx.Done()
|
||||
close(d.canceled)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (d *recordingProxyDialer) Dial(network, address string) (net.Conn, error) {
|
||||
return d.DialContext(context.Background(), network, address)
|
||||
}
|
||||
|
||||
func (d *recordingProxyDialer) DialContext(ctx context.Context, _ string, address string) (net.Conn, error) {
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return nil, errContext
|
||||
}
|
||||
d.mu.Lock()
|
||||
channel := d.dials
|
||||
errDial := d.err
|
||||
d.mu.Unlock()
|
||||
if errDial != nil {
|
||||
channel <- recordedProxyDial{address: address}
|
||||
return nil, errDial
|
||||
}
|
||||
client, server := net.Pipe()
|
||||
channel <- recordedProxyDial{address: address, connection: server}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func TestPrepareProxiedUpstreamAnswerRestrictsAndRewritesCandidates(t *testing.T) {
|
||||
dialer := &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
answer := testProxySDP("remote-ufrag", "remote-password", []string{
|
||||
"1 1 udp 2130706431 20.42.0.10 3478 typ host",
|
||||
"2 1 tcp 1671430143 20.42.0.20 443 typ host tcptype passive",
|
||||
})
|
||||
localOffer := testProxySDP("local-ufrag", "local-password", nil)
|
||||
|
||||
rewritten, tunnels, errPrepare := prepareProxiedUpstreamAnswer(answer, localOffer, dialer)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("prepareProxiedUpstreamAnswer returned error: %v", errPrepare)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := closeCandidateTunnels(tunnels); errClose != nil {
|
||||
t.Errorf("close candidate tunnels: %v", errClose)
|
||||
}
|
||||
}()
|
||||
if len(tunnels) != 1 {
|
||||
t.Fatalf("tunnel count = %d, want 1", len(tunnels))
|
||||
}
|
||||
if got := tunnels[0].target.String(); got != "20.42.0.20:443" {
|
||||
t.Fatalf("fixed target = %q, want 20.42.0.20:443", got)
|
||||
}
|
||||
if tunnels[0].expectedUser != "remote-ufrag:local-ufrag" {
|
||||
t.Fatalf("expected STUN username = %q", tunnels[0].expectedUser)
|
||||
}
|
||||
|
||||
var description sdp.SessionDescription
|
||||
if errUnmarshal := description.UnmarshalString(rewritten); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal rewritten SDP: %v", errUnmarshal)
|
||||
}
|
||||
var candidates []string
|
||||
for _, media := range description.MediaDescriptions {
|
||||
for _, attribute := range media.Attributes {
|
||||
if attribute.IsICECandidate() {
|
||||
candidates = append(candidates, attribute.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("rewritten candidate count = %d, want 1: %v", len(candidates), candidates)
|
||||
}
|
||||
fields := strings.Fields(candidates[0])
|
||||
if len(fields) < 8 || fields[2] != "tcp" || fields[4] != "127.0.0.1" || fields[5] == "443" {
|
||||
t.Fatalf("rewritten candidate = %q", candidates[0])
|
||||
}
|
||||
if !strings.Contains(candidates[0], "tcptype passive") {
|
||||
t.Fatalf("rewritten candidate lost passive TCP type: %q", candidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProxiedUpstreamAnswerRejectsUnsafeTargets(t *testing.T) {
|
||||
for name, candidate := range map[string]string{
|
||||
"private target": "1 1 tcp 1671430143 10.0.0.1 443 typ host tcptype passive",
|
||||
"zero network target": "1 1 tcp 1671430143 0.0.0.1 443 typ host tcptype passive",
|
||||
"carrier NAT target": "1 1 tcp 1671430143 100.64.0.1 443 typ host tcptype passive",
|
||||
"reserved target": "1 1 tcp 1671430143 203.0.113.10 443 typ host tcptype passive",
|
||||
"site-local IPv6 target": "1 1 tcp 1671430143 fec0::1 443 typ host tcptype passive",
|
||||
"wrong port": "1 1 tcp 1671430143 20.42.0.10 8443 typ host tcptype passive",
|
||||
"relay target": "1 1 tcp 1671430143 20.42.0.10 443 typ relay raddr 192.0.2.1 rport 5000 tcptype passive",
|
||||
"active target": "1 1 tcp 1671430143 20.42.0.10 443 typ host tcptype active",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dialer := &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
_, tunnels, errPrepare := prepareProxiedUpstreamAnswer(
|
||||
testProxySDP("remote", "remote-password", []string{candidate}),
|
||||
testProxySDP("local", "local-password", nil),
|
||||
dialer,
|
||||
)
|
||||
if errPrepare == nil {
|
||||
_ = closeCandidateTunnels(tunnels)
|
||||
t.Fatal("expected unsafe candidate to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProxiedUpstreamAnswerLimitsCandidateCount(t *testing.T) {
|
||||
candidates := make([]string, 0, maxUpstreamICECandidates+1)
|
||||
for index := 0; index <= maxUpstreamICECandidates; index++ {
|
||||
candidates = append(candidates, fmt.Sprintf("%d 1 udp 2130706431 20.42.0.10 3478 typ host", index+1))
|
||||
}
|
||||
_, tunnels, errPrepare := prepareProxiedUpstreamAnswer(
|
||||
testProxySDP("remote", "remote-password", candidates),
|
||||
testProxySDP("local", "local-password", nil),
|
||||
&recordingProxyDialer{dials: make(chan recordedProxyDial, 1)},
|
||||
)
|
||||
if errPrepare == nil || !strings.Contains(errPrepare.Error(), "candidate limit") {
|
||||
_ = closeCandidateTunnels(tunnels)
|
||||
t.Fatalf("error = %v, want candidate limit", errPrepare)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadValidatedICEBindingFrame(t *testing.T) {
|
||||
validFrame := buildTestICEFrame(t, "remote:local", "remote-password", true)
|
||||
for name, testCase := range map[string]struct {
|
||||
frame []byte
|
||||
expectedUser string
|
||||
password string
|
||||
wantError bool
|
||||
}{
|
||||
"valid": {
|
||||
frame: validFrame,
|
||||
expectedUser: "remote:local",
|
||||
password: "remote-password",
|
||||
},
|
||||
"wrong username": {
|
||||
frame: validFrame,
|
||||
expectedUser: "local:remote",
|
||||
password: "remote-password",
|
||||
wantError: true,
|
||||
},
|
||||
"wrong password": {
|
||||
frame: validFrame,
|
||||
expectedUser: "remote:local",
|
||||
password: "local-password",
|
||||
wantError: true,
|
||||
},
|
||||
"missing fingerprint": {
|
||||
frame: buildTestICEFrame(t, "remote:local", "remote-password", false),
|
||||
expectedUser: "remote:local",
|
||||
password: "remote-password",
|
||||
wantError: true,
|
||||
},
|
||||
"undersized": {
|
||||
frame: []byte{0, 1, 0},
|
||||
wantError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
validated, errValidate := readValidatedICEBindingFrame(
|
||||
&fragmentedReader{data: testCase.frame, maximum: 3},
|
||||
testCase.expectedUser,
|
||||
testCase.password,
|
||||
)
|
||||
if testCase.wantError {
|
||||
if errValidate == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if errValidate != nil {
|
||||
t.Fatalf("readValidatedICEBindingFrame returned error: %v", errValidate)
|
||||
}
|
||||
if !bytes.Equal(validated, testCase.frame) {
|
||||
t.Fatal("validated frame changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPCandidateTunnelAuthenticatesBeforeFixedTargetDial(t *testing.T) {
|
||||
dialer := &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(
|
||||
netip.MustParseAddrPort("20.42.0.20:443"),
|
||||
dialer,
|
||||
"remote:local",
|
||||
"remote-password",
|
||||
)
|
||||
if errTunnel != nil {
|
||||
t.Fatalf("newTCPCandidateTunnel returned error: %v", errTunnel)
|
||||
}
|
||||
defer func() { _ = tunnel.Close() }()
|
||||
forwardingStarted := make(chan struct{}, 1)
|
||||
tunnel.setForwardingStartedHandler(func() {
|
||||
forwardingStarted <- struct{}{}
|
||||
})
|
||||
|
||||
client, errDial := net.Dial("tcp", tunnel.listener.Addr().String())
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial candidate listener: %v", errDial)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
frame := buildTestICEFrame(t, "remote:local", "remote-password", true)
|
||||
if errWrite := writeAll(client, frame); errWrite != nil {
|
||||
t.Fatalf("write authenticated frame: %v", errWrite)
|
||||
}
|
||||
|
||||
var dial recordedProxyDial
|
||||
select {
|
||||
case dial = <-dialer.dials:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("proxy dial was not attempted after STUN authentication")
|
||||
}
|
||||
defer func() { _ = dial.connection.Close() }()
|
||||
if dial.address != "20.42.0.20:443" {
|
||||
t.Fatalf("proxy target = %q, want fixed candidate", dial.address)
|
||||
}
|
||||
forwarded := make([]byte, len(frame))
|
||||
if _, errRead := io.ReadFull(dial.connection, forwarded); errRead != nil {
|
||||
t.Fatalf("read forwarded STUN frame: %v", errRead)
|
||||
}
|
||||
if !bytes.Equal(forwarded, frame) {
|
||||
t.Fatal("forwarded STUN frame changed")
|
||||
}
|
||||
select {
|
||||
case <-forwardingStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("forwarding start handler was not called")
|
||||
}
|
||||
if errWrite := writeAll(dial.connection, []byte("reply")); errWrite != nil {
|
||||
t.Fatalf("write tunnel reply: %v", errWrite)
|
||||
}
|
||||
reply := make([]byte, len("reply"))
|
||||
if _, errRead := io.ReadFull(client, reply); errRead != nil {
|
||||
t.Fatalf("read tunnel reply: %v", errRead)
|
||||
}
|
||||
if string(reply) != "reply" {
|
||||
t.Fatalf("tunnel reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPCandidateTunnelCloseCancelsProxyDial(t *testing.T) {
|
||||
dialer := &blockingContextDialer{
|
||||
started: make(chan struct{}),
|
||||
canceled: make(chan struct{}),
|
||||
}
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(
|
||||
netip.MustParseAddrPort("20.42.0.20:443"),
|
||||
dialer,
|
||||
"remote:local",
|
||||
"remote-password",
|
||||
)
|
||||
if errTunnel != nil {
|
||||
t.Fatalf("newTCPCandidateTunnel returned error: %v", errTunnel)
|
||||
}
|
||||
client, errDial := net.Dial("tcp", tunnel.listener.Addr().String())
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial candidate listener: %v", errDial)
|
||||
}
|
||||
if errWrite := writeAll(client, buildTestICEFrame(t, "remote:local", "remote-password", true)); errWrite != nil {
|
||||
t.Fatalf("write authenticated frame: %v", errWrite)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
select {
|
||||
case <-dialer.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("proxy dial did not start")
|
||||
}
|
||||
forwardingStarted := make(chan struct{}, 1)
|
||||
tunnel.setForwardingStartedHandler(func() { forwardingStarted <- struct{}{} })
|
||||
if errClose := tunnel.Close(); errClose != nil {
|
||||
t.Fatalf("close tunnel: %v", errClose)
|
||||
}
|
||||
select {
|
||||
case <-dialer.canceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tunnel close did not cancel proxy dial")
|
||||
}
|
||||
assertNoForwardingStart(t, forwardingStarted)
|
||||
}
|
||||
|
||||
func TestTCPCandidateTunnelProxyFailureDoesNotFallBack(t *testing.T) {
|
||||
dialer := &recordingProxyDialer{
|
||||
dials: make(chan recordedProxyDial, 1),
|
||||
err: errors.New("proxy blocked"),
|
||||
}
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(
|
||||
netip.MustParseAddrPort("20.42.0.20:443"),
|
||||
dialer,
|
||||
"remote:local",
|
||||
"remote-password",
|
||||
)
|
||||
if errTunnel != nil {
|
||||
t.Fatalf("newTCPCandidateTunnel returned error: %v", errTunnel)
|
||||
}
|
||||
defer func() { _ = tunnel.Close() }()
|
||||
forwardingStarted := make(chan struct{}, 1)
|
||||
tunnel.setForwardingStartedHandler(func() { forwardingStarted <- struct{}{} })
|
||||
client, errDial := net.Dial("tcp", tunnel.listener.Addr().String())
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial candidate listener: %v", errDial)
|
||||
}
|
||||
if errWrite := writeAll(client, buildTestICEFrame(t, "remote:local", "remote-password", true)); errWrite != nil {
|
||||
t.Fatalf("write authenticated frame: %v", errWrite)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
select {
|
||||
case dial := <-dialer.dials:
|
||||
if dial.address != "20.42.0.20:443" || dial.connection != nil {
|
||||
t.Fatalf("failed proxy dial = %#v", dial)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("proxy dial was not attempted")
|
||||
}
|
||||
if _, errSecondDial := net.Dial("tcp", tunnel.listener.Addr().String()); errSecondDial == nil {
|
||||
t.Fatal("candidate listener remained available after proxy failure")
|
||||
}
|
||||
assertNoForwardingStart(t, forwardingStarted)
|
||||
}
|
||||
|
||||
func TestTCPCandidateTunnelWriteFailureDoesNotLogForwardingStart(t *testing.T) {
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(
|
||||
netip.MustParseAddrPort("20.42.0.20:443"),
|
||||
&closedUpstreamDialer{},
|
||||
"remote:local",
|
||||
"remote-password",
|
||||
)
|
||||
if errTunnel != nil {
|
||||
t.Fatalf("newTCPCandidateTunnel returned error: %v", errTunnel)
|
||||
}
|
||||
defer func() { _ = tunnel.Close() }()
|
||||
forwardingStarted := make(chan struct{}, 1)
|
||||
tunnel.setForwardingStartedHandler(func() { forwardingStarted <- struct{}{} })
|
||||
client, errDial := net.Dial("tcp", tunnel.listener.Addr().String())
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial candidate listener: %v", errDial)
|
||||
}
|
||||
if errWrite := writeAll(client, buildTestICEFrame(t, "remote:local", "remote-password", true)); errWrite != nil {
|
||||
t.Fatalf("write authenticated frame: %v", errWrite)
|
||||
}
|
||||
_ = client.Close()
|
||||
assertNoForwardingStart(t, forwardingStarted)
|
||||
}
|
||||
|
||||
func TestTCPCandidateTunnelRejectsUnauthenticatedConnectionWithoutDial(t *testing.T) {
|
||||
dialer := &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
tunnel, errTunnel := newTCPCandidateTunnel(
|
||||
netip.MustParseAddrPort("20.42.0.20:443"),
|
||||
dialer,
|
||||
"remote:local",
|
||||
"remote-password",
|
||||
)
|
||||
if errTunnel != nil {
|
||||
t.Fatalf("newTCPCandidateTunnel returned error: %v", errTunnel)
|
||||
}
|
||||
defer func() { _ = tunnel.Close() }()
|
||||
forwardingStarted := make(chan struct{}, 1)
|
||||
tunnel.setForwardingStartedHandler(func() { forwardingStarted <- struct{}{} })
|
||||
|
||||
client, errDial := net.Dial("tcp", tunnel.listener.Addr().String())
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial candidate listener: %v", errDial)
|
||||
}
|
||||
if errWrite := writeAll(client, buildTestICEFrame(t, "attacker:local", "remote-password", true)); errWrite != nil {
|
||||
t.Fatalf("write unauthenticated frame: %v", errWrite)
|
||||
}
|
||||
_ = client.Close()
|
||||
select {
|
||||
case dial := <-dialer.dials:
|
||||
_ = dial.connection.Close()
|
||||
t.Fatalf("unauthenticated connection triggered proxy dial to %q", dial.address)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
assertNoForwardingStart(t, forwardingStarted)
|
||||
}
|
||||
|
||||
func assertNoForwardingStart(t *testing.T, started <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-started:
|
||||
t.Fatal("forwarding start handler was called for an unestablished tunnel")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestPionActiveTCPCandidatePassesTunnelAuthentication(t *testing.T) {
|
||||
localAPI, errAPI := newPionProxyAPI(config.CodexLiveMediaRelayConfig{})
|
||||
if errAPI != nil {
|
||||
t.Fatalf("create local Pion API: %v", errAPI)
|
||||
}
|
||||
localPeer, errPeer := localAPI.NewPeerConnection(webrtc.Configuration{})
|
||||
if errPeer != nil {
|
||||
t.Fatalf("create local PeerConnection: %v", errPeer)
|
||||
}
|
||||
defer func() { _ = localPeer.Close() }()
|
||||
if _, errChannel := localPeer.CreateDataChannel(realtimeDataChannelLabel, nil); errChannel != nil {
|
||||
t.Fatalf("create local DataChannel: %v", errChannel)
|
||||
}
|
||||
localGathering := webrtc.GatheringCompletePromise(localPeer)
|
||||
localOffer, errOffer := localPeer.CreateOffer(nil)
|
||||
if errOffer != nil {
|
||||
t.Fatalf("create local offer: %v", errOffer)
|
||||
}
|
||||
if errLocal := localPeer.SetLocalDescription(localOffer); errLocal != nil {
|
||||
t.Fatalf("set local offer: %v", errLocal)
|
||||
}
|
||||
<-localGathering
|
||||
localDescription := localPeer.LocalDescription()
|
||||
if localDescription == nil {
|
||||
t.Fatal("local description is nil")
|
||||
}
|
||||
|
||||
tcpListener, errListen := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if errListen != nil {
|
||||
t.Fatalf("listen for remote ICE-TCP: %v", errListen)
|
||||
}
|
||||
remoteSettings := webrtc.SettingEngine{}
|
||||
remoteSettings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeTCP4})
|
||||
remoteSettings.SetIncludeLoopbackCandidate(true)
|
||||
remoteSettings.SetIPFilter(func(ip net.IP) bool { return ip != nil && ip.IsLoopback() })
|
||||
tcpMux := webrtc.NewICETCPMux(nil, tcpListener, 8)
|
||||
remoteSettings.SetICETCPMux(tcpMux)
|
||||
defer func() { _ = tcpMux.Close() }()
|
||||
remoteAPI := webrtc.NewAPI(webrtc.WithSettingEngine(remoteSettings))
|
||||
remotePeer, errPeer := remoteAPI.NewPeerConnection(webrtc.Configuration{})
|
||||
if errPeer != nil {
|
||||
t.Fatalf("create remote PeerConnection: %v", errPeer)
|
||||
}
|
||||
defer func() { _ = remotePeer.Close() }()
|
||||
if errRemote := remotePeer.SetRemoteDescription(*localDescription); errRemote != nil {
|
||||
t.Fatalf("set remote offer: %v", errRemote)
|
||||
}
|
||||
remoteGathering := webrtc.GatheringCompletePromise(remotePeer)
|
||||
remoteAnswer, errAnswer := remotePeer.CreateAnswer(nil)
|
||||
if errAnswer != nil {
|
||||
t.Fatalf("create remote answer: %v", errAnswer)
|
||||
}
|
||||
if errLocal := remotePeer.SetLocalDescription(remoteAnswer); errLocal != nil {
|
||||
t.Fatalf("set remote answer: %v", errLocal)
|
||||
}
|
||||
<-remoteGathering
|
||||
remoteDescription := remotePeer.LocalDescription()
|
||||
if remoteDescription == nil {
|
||||
t.Fatal("remote description is nil")
|
||||
}
|
||||
publicAnswer := rewriteTestTCPCandidateTarget(t, remoteDescription.SDP, "20.42.0.20", 443)
|
||||
|
||||
dialer := &recordingProxyDialer{dials: make(chan recordedProxyDial, 1)}
|
||||
rewrittenAnswer, tunnels, errPrepare := prepareProxiedUpstreamAnswer(publicAnswer, localDescription.SDP, dialer)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("prepare proxied Pion answer: %v", errPrepare)
|
||||
}
|
||||
defer func() { _ = closeCandidateTunnels(tunnels) }()
|
||||
if errRemote := localPeer.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: rewrittenAnswer,
|
||||
}); errRemote != nil {
|
||||
t.Fatalf("set rewritten remote answer: %v", errRemote)
|
||||
}
|
||||
|
||||
var dial recordedProxyDial
|
||||
select {
|
||||
case dial = <-dialer.dials:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Pion active ICE-TCP did not reach the authenticated tunnel")
|
||||
}
|
||||
defer func() { _ = dial.connection.Close() }()
|
||||
localCredentials, errCredentials := bundledICECredentialsFromString(localDescription.SDP)
|
||||
if errCredentials != nil {
|
||||
t.Fatalf("read local credentials: %v", errCredentials)
|
||||
}
|
||||
remoteCredentials, errCredentials := bundledICECredentialsFromString(publicAnswer)
|
||||
if errCredentials != nil {
|
||||
t.Fatalf("read remote credentials: %v", errCredentials)
|
||||
}
|
||||
if _, errValidate := readValidatedICEBindingFrame(
|
||||
dial.connection,
|
||||
remoteCredentials.ufrag+":"+localCredentials.ufrag,
|
||||
remoteCredentials.password,
|
||||
); errValidate != nil {
|
||||
t.Fatalf("forwarded Pion STUN request failed validation: %v", errValidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundledICECredentialsRejectsMixedCredentials(t *testing.T) {
|
||||
mixed := strings.Replace(
|
||||
testProxySDP("first", "first-password", nil),
|
||||
"a=mid:1\r\na=ice-ufrag:first\r\na=ice-pwd:first-password",
|
||||
"a=mid:1\r\na=ice-ufrag:second\r\na=ice-pwd:second-password",
|
||||
1,
|
||||
)
|
||||
var description sdp.SessionDescription
|
||||
if errUnmarshal := description.UnmarshalString(mixed); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal mixed SDP: %v", errUnmarshal)
|
||||
}
|
||||
if _, errCredentials := bundledICECredentials(&description); errCredentials == nil {
|
||||
t.Fatal("expected inconsistent bundled credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestICEFrame(t *testing.T, username, password string, fingerprint bool) []byte {
|
||||
t.Helper()
|
||||
setters := []stun.Setter{
|
||||
stun.BindingRequest,
|
||||
stun.TransactionID,
|
||||
stun.NewUsername(username),
|
||||
stun.NewShortTermIntegrity(password),
|
||||
}
|
||||
if fingerprint {
|
||||
setters = append(setters, stun.Fingerprint)
|
||||
}
|
||||
message, errBuild := stun.Build(setters...)
|
||||
if errBuild != nil {
|
||||
t.Fatalf("build STUN request: %v", errBuild)
|
||||
}
|
||||
if len(message.Raw) > int(^uint16(0)) {
|
||||
t.Fatal("test STUN request is too large")
|
||||
}
|
||||
frame := make([]byte, 2+len(message.Raw))
|
||||
binary.BigEndian.PutUint16(frame[:2], uint16(len(message.Raw)))
|
||||
copy(frame[2:], message.Raw)
|
||||
return frame
|
||||
}
|
||||
|
||||
func testProxySDP(ufrag, password string, candidates []string) string {
|
||||
var builder strings.Builder
|
||||
_, _ = fmt.Fprintf(&builder, "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\n")
|
||||
for _, media := range []struct {
|
||||
line string
|
||||
mid string
|
||||
}{
|
||||
{line: "m=audio 9 UDP/TLS/RTP/SAVPF 111", mid: "0"},
|
||||
{line: "m=application 9 UDP/DTLS/SCTP webrtc-datachannel", mid: "1"},
|
||||
} {
|
||||
_, _ = fmt.Fprintf(&builder, "%s\r\nc=IN IP4 0.0.0.0\r\na=mid:%s\r\na=ice-ufrag:%s\r\na=ice-pwd:%s\r\n", media.line, media.mid, ufrag, password)
|
||||
if media.mid == "0" {
|
||||
for _, candidate := range candidates {
|
||||
_, _ = fmt.Fprintf(&builder, "a=candidate:%s\r\n", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type fragmentedReader struct {
|
||||
data []byte
|
||||
maximum int
|
||||
}
|
||||
|
||||
func (r *fragmentedReader) Read(destination []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
limit := len(destination)
|
||||
if limit > r.maximum {
|
||||
limit = r.maximum
|
||||
}
|
||||
if limit > len(r.data) {
|
||||
limit = len(r.data)
|
||||
}
|
||||
copy(destination, r.data[:limit])
|
||||
r.data = r.data[limit:]
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func rewriteTestTCPCandidateTarget(t *testing.T, rawSDP, address string, port int) string {
|
||||
t.Helper()
|
||||
var description sdp.SessionDescription
|
||||
if errUnmarshal := description.UnmarshalString(rawSDP); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal test SDP: %v", errUnmarshal)
|
||||
}
|
||||
rewritten := 0
|
||||
for _, media := range description.MediaDescriptions {
|
||||
for index := range media.Attributes {
|
||||
attribute := &media.Attributes[index]
|
||||
if !attribute.IsICECandidate() {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(attribute.Value)
|
||||
if len(fields) < 8 || !strings.EqualFold(fields[2], "tcp") || !strings.Contains(attribute.Value, "tcptype passive") {
|
||||
continue
|
||||
}
|
||||
fields[4] = address
|
||||
fields[5] = strconv.Itoa(port)
|
||||
attribute.Value = strings.Join(fields, " ")
|
||||
rewritten++
|
||||
}
|
||||
}
|
||||
if rewritten == 0 {
|
||||
t.Fatal("test SDP has no passive TCP candidate")
|
||||
}
|
||||
marshaled, errMarshal := description.Marshal()
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("marshal test SDP: %v", errMarshal)
|
||||
}
|
||||
return string(marshaled)
|
||||
}
|
||||
|
||||
func bundledICECredentialsFromString(rawSDP string) (iceCredentials, error) {
|
||||
var description sdp.SessionDescription
|
||||
if errUnmarshal := description.UnmarshalString(rawSDP); errUnmarshal != nil {
|
||||
return iceCredentials{}, errUnmarshal
|
||||
}
|
||||
return bundledICECredentials(&description)
|
||||
}
|
||||
251
backend/internal/client/codex/live/websocket.go
Normal file
251
backend/internal/client/codex/live/websocket.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
203
backend/internal/client/codex/live/websocket_test.go
Normal file
203
backend/internal/client/codex/live/websocket_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestHandleDirectWebsocketRejectsClientSecretModelMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(auth.NewManager(nil, nil, nil), nil)
|
||||
router := gin.New()
|
||||
router.GET("/v1/realtime", func(c *gin.Context) {
|
||||
c.Set(ClientSecretSessionContextKey, json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex"}`))
|
||||
c.Set(ClientSecretPrincipalContextKey, "sess_123")
|
||||
c.Next()
|
||||
}, handler.HandleRealtimeWebsocket)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=another-live-model", nil)
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "websocket")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusForbidden, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDirectWebsocketAppliesClientSecretSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstreamUpdate := make(chan []byte, 1)
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
connection, errUpgrade := upgrader.Upgrade(writer, request, nil)
|
||||
if errUpgrade != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = connection.Close() }()
|
||||
_, payload, errRead := connection.ReadMessage()
|
||||
if errRead != nil {
|
||||
return
|
||||
}
|
||||
upstreamUpdate <- append([]byte(nil), payload...)
|
||||
_ = connection.WriteMessage(websocket.TextMessage, []byte(`{"type":"session.created"}`))
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
manager := auth.NewManager(nil, nil, nil)
|
||||
manager.RegisterExecutor(&captureExecutor{})
|
||||
registerCredential(t, manager, &auth.Auth{
|
||||
ID: "codex-oauth",
|
||||
Provider: "codex",
|
||||
Status: auth.StatusActive,
|
||||
Metadata: map[string]any{"access_token": "oauth-token"},
|
||||
})
|
||||
handler := NewHandler(manager, nil)
|
||||
handler.sidebandAPIBaseURL = "ws" + strings.TrimPrefix(upstreamServer.URL, "http") + "/v1"
|
||||
router := gin.New()
|
||||
router.GET("/v1/realtime", func(c *gin.Context) {
|
||||
c.Set(ClientSecretSessionContextKey, json.RawMessage(`{"type":"realtime","model":"gpt-live-1-codex","instructions":"help"}`))
|
||||
c.Set(ClientSecretPrincipalContextKey, "sess_123")
|
||||
c.Next()
|
||||
}, handler.HandleRealtimeWebsocket)
|
||||
downstreamServer := httptest.NewServer(router)
|
||||
defer downstreamServer.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(downstreamServer.URL, "http") + "/v1/realtime?model=gpt-realtime"
|
||||
connection, _, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial downstream websocket: %v", errDial)
|
||||
}
|
||||
defer func() { _ = connection.Close() }()
|
||||
_, _, _ = connection.ReadMessage()
|
||||
|
||||
select {
|
||||
case update := <-upstreamUpdate:
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Session struct {
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(update, &event); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal session update: %v", errUnmarshal)
|
||||
}
|
||||
if event.Type != "session.update" || event.Session.Model != "" || event.Session.Instructions != "help" {
|
||||
t.Fatalf("session update = %+v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("session update not captured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDirectWebsocketRelaysStandardRealtimeFrames(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstreamRequest := make(chan *http.Request, 1)
|
||||
upstreamMessage := make(chan string, 1)
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
connection, errUpgrade := upgrader.Upgrade(writer, request, nil)
|
||||
if errUpgrade != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = connection.Close() }()
|
||||
upstreamRequest <- request.Clone(request.Context())
|
||||
if errWrite := connection.WriteMessage(websocket.TextMessage, []byte(`{"type":"session.created"}`)); errWrite != nil {
|
||||
return
|
||||
}
|
||||
messageType, payload, errRead := connection.ReadMessage()
|
||||
if errRead != nil {
|
||||
return
|
||||
}
|
||||
upstreamMessage <- string(payload)
|
||||
_ = connection.WriteMessage(messageType, append([]byte("echo:"), payload...))
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
manager := auth.NewManager(nil, nil, nil)
|
||||
manager.RegisterExecutor(&captureExecutor{})
|
||||
registerCredential(t, manager, &auth.Auth{
|
||||
ID: "codex-oauth",
|
||||
Provider: "codex",
|
||||
Status: auth.StatusActive,
|
||||
Metadata: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"account_id": "account-123",
|
||||
},
|
||||
})
|
||||
handler := NewHandler(manager, nil)
|
||||
handler.sidebandAPIBaseURL = "ws" + strings.TrimPrefix(upstreamServer.URL, "http") + "/v1"
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/v1/realtime", handler.HandleRealtimeWebsocket)
|
||||
downstreamServer := httptest.NewServer(router)
|
||||
defer downstreamServer.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(downstreamServer.URL, "http") + "/v1/realtime?model=gpt-realtime"
|
||||
downstreamHeaders := make(http.Header)
|
||||
downstreamHeaders.Set("OpenAI-Alpha", "quicksilver=v2")
|
||||
connection, _, errDial := websocket.DefaultDialer.Dial(wsURL, downstreamHeaders)
|
||||
if errDial != nil {
|
||||
t.Fatalf("dial downstream websocket: %v", errDial)
|
||||
}
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
_, created, errRead := connection.ReadMessage()
|
||||
if errRead != nil {
|
||||
t.Fatalf("read session.created: %v", errRead)
|
||||
}
|
||||
if string(created) != `{"type":"session.created"}` {
|
||||
t.Fatalf("created event = %s", created)
|
||||
}
|
||||
const event = `{"type":"response.create"}`
|
||||
if errWrite := connection.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
|
||||
t.Fatalf("write downstream event: %v", errWrite)
|
||||
}
|
||||
_, echoed, errRead := connection.ReadMessage()
|
||||
if errRead != nil {
|
||||
t.Fatalf("read echoed event: %v", errRead)
|
||||
}
|
||||
if string(echoed) != "echo:"+event {
|
||||
t.Fatalf("echoed event = %s", echoed)
|
||||
}
|
||||
|
||||
select {
|
||||
case request := <-upstreamRequest:
|
||||
if request.Header.Get("Authorization") != "Bearer oauth-token" {
|
||||
t.Fatalf("Authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
if request.Header.Get("Chatgpt-Account-Id") != "account-123" {
|
||||
t.Fatalf("Chatgpt-Account-Id = %q", request.Header.Get("Chatgpt-Account-Id"))
|
||||
}
|
||||
if request.Header.Get("OpenAI-Alpha") != "" {
|
||||
t.Fatalf("OpenAI-Alpha must not be forwarded, got %q", request.Header.Get("OpenAI-Alpha"))
|
||||
}
|
||||
query, errParse := url.ParseQuery(request.URL.RawQuery)
|
||||
if errParse != nil {
|
||||
t.Fatalf("parse upstream query: %v", errParse)
|
||||
}
|
||||
if query.Get("model") != "gpt-realtime" || query.Has("intent") {
|
||||
t.Fatalf("upstream query = %v", query)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("upstream request not captured")
|
||||
}
|
||||
select {
|
||||
case payload := <-upstreamMessage:
|
||||
if payload != event {
|
||||
t.Fatalf("upstream event = %s", payload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("upstream event not captured")
|
||||
}
|
||||
}
|
||||
502
backend/internal/client/codex/models/models.go
Normal file
502
backend/internal/client/codex/models/models.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
// Package models builds model catalogs for official Codex clients.
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
type codexClientModelsPayload struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
|
||||
// ProvidersForModelFunc returns the providers registered for a model.
|
||||
type ProvidersForModelFunc func(string) []string
|
||||
|
||||
var (
|
||||
codexClientModelTemplatesMu sync.Mutex
|
||||
codexClientModelTemplatesLoaded bool
|
||||
codexClientModelTemplatesRevision uint64
|
||||
codexClientModelTemplates map[string]map[string]any
|
||||
codexClientDefaultTemplate map[string]any
|
||||
codexClientModelTemplatesErr error
|
||||
)
|
||||
|
||||
var codexClientAllowedReasoningLevels = map[string]struct{}{
|
||||
"none": {},
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
"medium": {},
|
||||
"high": {},
|
||||
"xhigh": {},
|
||||
"max": {},
|
||||
"ultra": {},
|
||||
}
|
||||
|
||||
// BuildResponse builds a Codex client model response from available models.
|
||||
func BuildResponse(availableModels []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool) map[string]any {
|
||||
return map[string]any{
|
||||
"models": buildCodexClientModels(availableModels, providersForModel, optimizeMultiAgentV2),
|
||||
}
|
||||
}
|
||||
|
||||
func buildCodexClientModels(models []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool) []map[string]any {
|
||||
templates, defaultTemplate, err := loadCodexClientModelTemplates()
|
||||
if err != nil || defaultTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(models))
|
||||
for _, model := range models {
|
||||
id := strings.TrimSpace(stringModelValue(model, "id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if template, ok := templates[id]; ok {
|
||||
entry := cloneCodexClientModelMap(template)
|
||||
applyCodexClientDisplayName(entry, model)
|
||||
applyCodexClientMaxContextLengthOverride(entry, model)
|
||||
applyCodexClientMaxTokens(entry, model)
|
||||
applyCodexClientSearchToolSupport(entry, id, true, providersForModel)
|
||||
sanitizeCodexClientReasoningMetadata(entry)
|
||||
applyCodexClientVisibilityOverride(entry, id)
|
||||
if optimizeMultiAgentV2 {
|
||||
entry["multi_agent_version"] = "v2"
|
||||
}
|
||||
result = append(result, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
entry := cloneCodexClientModelMap(defaultTemplate)
|
||||
applyCodexClientModelMetadata(entry, id, model, optimizeMultiAgentV2)
|
||||
applyCodexClientMaxTokens(entry, model)
|
||||
applyCodexClientSearchToolSupport(entry, id, false, providersForModel)
|
||||
sanitizeCodexClientReasoningMetadata(entry)
|
||||
applyCodexClientVisibilityOverride(entry, id)
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
applyCodexClientNonTemplatePriorities(result, templates)
|
||||
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
return codexClientModelPriority(result[i]) < codexClientModelPriority(result[j])
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func maxCodexClientTemplatePriority(templates map[string]map[string]any) int {
|
||||
maxPriority := 0
|
||||
for _, template := range templates {
|
||||
priority := codexClientModelPriority(template)
|
||||
if priority > maxPriority {
|
||||
maxPriority = priority
|
||||
}
|
||||
}
|
||||
return maxPriority
|
||||
}
|
||||
|
||||
func applyCodexClientNonTemplatePriorities(result []map[string]any, templates map[string]map[string]any) {
|
||||
if len(result) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
basePriority := maxCodexClientTemplatePriority(templates)
|
||||
type nonTemplateEntry struct {
|
||||
index int
|
||||
displayName string
|
||||
slug string
|
||||
}
|
||||
|
||||
pending := make([]nonTemplateEntry, 0)
|
||||
for index, entry := range result {
|
||||
slug := stringModelValue(entry, "slug")
|
||||
if _, ok := templates[slug]; ok {
|
||||
continue
|
||||
}
|
||||
displayName := stringModelValue(entry, "display_name")
|
||||
if displayName == "" {
|
||||
displayName = slug
|
||||
}
|
||||
pending = append(pending, nonTemplateEntry{
|
||||
index: index,
|
||||
displayName: displayName,
|
||||
slug: slug,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(pending, func(i, j int) bool {
|
||||
left := strings.ToLower(pending[i].displayName)
|
||||
right := strings.ToLower(pending[j].displayName)
|
||||
if left == right {
|
||||
return pending[i].slug < pending[j].slug
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
|
||||
for rank, entry := range pending {
|
||||
result[entry.index]["priority"] = basePriority + 100*(rank+1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCodexClientModelTemplates() (map[string]map[string]any, map[string]any, error) {
|
||||
raw, revision := registry.GetCodexClientModelsSnapshot()
|
||||
return loadCodexClientModelTemplatesSnapshot(raw, revision)
|
||||
}
|
||||
|
||||
func loadCodexClientModelTemplatesSnapshot(raw []byte, revision uint64) (map[string]map[string]any, map[string]any, error) {
|
||||
codexClientModelTemplatesMu.Lock()
|
||||
defer codexClientModelTemplatesMu.Unlock()
|
||||
if codexClientModelTemplatesLoaded && codexClientModelTemplatesRevision == revision {
|
||||
return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr
|
||||
}
|
||||
|
||||
var payload codexClientModelsPayload
|
||||
err := json.Unmarshal(raw, &payload)
|
||||
var templates map[string]map[string]any
|
||||
var defaultTemplate map[string]any
|
||||
if err == nil {
|
||||
templates = make(map[string]map[string]any, len(payload.Models))
|
||||
for _, model := range payload.Models {
|
||||
slug := strings.TrimSpace(stringModelValue(model, "slug"))
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
templates[slug] = cloneCodexClientModelMap(model)
|
||||
if slug == "gpt-5.5" {
|
||||
defaultTemplate = cloneCodexClientModelMap(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codexClientModelTemplatesLoaded = true
|
||||
codexClientModelTemplatesRevision = revision
|
||||
codexClientModelTemplates = templates
|
||||
codexClientDefaultTemplate = defaultTemplate
|
||||
codexClientModelTemplatesErr = err
|
||||
return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr
|
||||
}
|
||||
|
||||
func applyCodexClientDisplayName(entry map[string]any, model map[string]any) {
|
||||
if displayName := stringModelValue(model, "display_name"); displayName != "" {
|
||||
entry["display_name"] = displayName
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientMaxContextLengthOverride(entry map[string]any, model map[string]any) {
|
||||
if maxContextLength := intModelValue(model, "max_context_length"); maxContextLength > 0 {
|
||||
entry["context_window"] = maxContextLength
|
||||
entry["max_context_window"] = maxContextLength
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientMaxTokens(entry map[string]any, model map[string]any) {
|
||||
if maxCompletionTokens := intModelValue(model, "max_completion_tokens"); maxCompletionTokens > 0 {
|
||||
entry["max_tokens"] = maxCompletionTokens
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientSearchToolSupport(entry map[string]any, id string, templateModel bool, providersForModel ProvidersForModelFunc) {
|
||||
supportsSearch, _ := entry["supports_search_tool"].(bool)
|
||||
if !supportsSearch {
|
||||
return
|
||||
}
|
||||
|
||||
if !templateModel {
|
||||
entry["supports_search_tool"] = false
|
||||
return
|
||||
}
|
||||
|
||||
if providersForModel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
providers := providersForModel(id)
|
||||
if len(providers) == 0 {
|
||||
entry["supports_search_tool"] = false
|
||||
return
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if !strings.EqualFold(strings.TrimSpace(provider), "codex") {
|
||||
entry["supports_search_tool"] = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any, optimizeMultiAgentV2 bool) {
|
||||
info := registry.LookupModelInfo(id)
|
||||
|
||||
displayName := stringModelValue(model, "display_name")
|
||||
description := stringModelValue(model, "description")
|
||||
contextWindow := intModelValue(model, "context_length")
|
||||
|
||||
if info != nil {
|
||||
if info.DisplayName != "" {
|
||||
displayName = info.DisplayName
|
||||
}
|
||||
if info.Description != "" {
|
||||
description = info.Description
|
||||
}
|
||||
if info.ContextLength > 0 {
|
||||
contextWindow = info.ContextLength
|
||||
}
|
||||
if info.Type == registry.OpenAIImageModelType {
|
||||
entry["visibility"] = "hide"
|
||||
delete(entry, "input_modalities")
|
||||
delete(entry, "supports_image_detail_original")
|
||||
} else {
|
||||
applyCodexClientInputModalitiesMetadata(entry, info.SupportedInputModalities)
|
||||
}
|
||||
applyCodexClientThinkingMetadata(entry, info.Thinking)
|
||||
}
|
||||
|
||||
if maxContextWindow := intModelValue(model, "max_context_length"); maxContextWindow > 0 {
|
||||
contextWindow = maxContextWindow
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = id
|
||||
}
|
||||
if description == "" {
|
||||
description = id
|
||||
}
|
||||
|
||||
entry["slug"] = id
|
||||
entry["display_name"] = displayName
|
||||
entry["description"] = description
|
||||
entry["prefer_websockets"] = false
|
||||
if optimizeMultiAgentV2 {
|
||||
entry["multi_agent_version"] = "v2"
|
||||
}
|
||||
entry["service_tiers"] = []any{}
|
||||
delete(entry, "apply_patch_tool_type")
|
||||
delete(entry, "upgrade")
|
||||
delete(entry, "availability_nux")
|
||||
|
||||
if contextWindow > 0 {
|
||||
entry["context_window"] = contextWindow
|
||||
entry["max_context_window"] = contextWindow
|
||||
}
|
||||
|
||||
if baseInstructions := stringModelValue(model, "base_instructions"); baseInstructions != "" {
|
||||
entry["base_instructions"] = baseInstructions
|
||||
}
|
||||
if plans, ok := model["available_in_plans"]; ok {
|
||||
entry["available_in_plans"] = cloneCodexClientModelValue(plans)
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientVisibilityOverride(entry map[string]any, id string) {
|
||||
switch strings.TrimSpace(id) {
|
||||
case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "grok-imagine-image", "grok-imagine-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview":
|
||||
entry["visibility"] = "hide"
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientInputModalitiesMetadata(entry map[string]any, modalities []string) {
|
||||
if len(modalities) == 0 {
|
||||
return
|
||||
}
|
||||
// Codex client only accepts text/image input modalities.
|
||||
codexModalities := make([]any, 0, 2)
|
||||
seen := make(map[string]struct{}, 2)
|
||||
supportsImage := false
|
||||
for _, raw := range modalities {
|
||||
switch modality := strings.ToLower(strings.TrimSpace(raw)); modality {
|
||||
case "text", "image":
|
||||
if _, ok := seen[modality]; ok {
|
||||
continue
|
||||
}
|
||||
seen[modality] = struct{}{}
|
||||
codexModalities = append(codexModalities, modality)
|
||||
if modality == "image" {
|
||||
supportsImage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(codexModalities) == 0 {
|
||||
return
|
||||
}
|
||||
entry["input_modalities"] = codexModalities
|
||||
if supportsImage {
|
||||
entry["supports_image_detail_original"] = true
|
||||
} else {
|
||||
delete(entry, "supports_image_detail_original")
|
||||
}
|
||||
}
|
||||
|
||||
func applyCodexClientThinkingMetadata(entry map[string]any, thinking *registry.ThinkingSupport) {
|
||||
if thinking == nil || len(thinking.Levels) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
levels := make([]any, 0, len(thinking.Levels))
|
||||
defaultLevel := ""
|
||||
firstLevel := ""
|
||||
for _, rawLevel := range thinking.Levels {
|
||||
level := normalizeCodexClientReasoningLevel(rawLevel)
|
||||
if level == "" {
|
||||
continue
|
||||
}
|
||||
if firstLevel == "" {
|
||||
firstLevel = level
|
||||
}
|
||||
if (defaultLevel == "" && level != "none") || level == "medium" {
|
||||
defaultLevel = level
|
||||
}
|
||||
levels = append(levels, map[string]any{
|
||||
"effort": level,
|
||||
"description": codexClientReasoningDescription(level),
|
||||
})
|
||||
}
|
||||
if len(levels) == 0 {
|
||||
return
|
||||
}
|
||||
if defaultLevel == "" {
|
||||
defaultLevel = firstLevel
|
||||
}
|
||||
|
||||
entry["supported_reasoning_levels"] = levels
|
||||
entry["default_reasoning_level"] = defaultLevel
|
||||
}
|
||||
|
||||
func sanitizeCodexClientReasoningMetadata(entry map[string]any) {
|
||||
rawLevels, ok := entry["supported_reasoning_levels"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
levels := make([]any, 0, len(rawLevels))
|
||||
allowedDefaults := make(map[string]struct{}, len(rawLevels))
|
||||
for _, rawLevelEntry := range rawLevels {
|
||||
levelEntry, ok := rawLevelEntry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
level := normalizeCodexClientReasoningLevel(stringModelValue(levelEntry, "effort"))
|
||||
if level == "" {
|
||||
continue
|
||||
}
|
||||
clonedEntry := cloneCodexClientModelMap(levelEntry)
|
||||
clonedEntry["effort"] = level
|
||||
levels = append(levels, clonedEntry)
|
||||
allowedDefaults[level] = struct{}{}
|
||||
}
|
||||
|
||||
if len(levels) == 0 {
|
||||
delete(entry, "supported_reasoning_levels")
|
||||
delete(entry, "default_reasoning_level")
|
||||
return
|
||||
}
|
||||
|
||||
defaultLevel := normalizeCodexClientReasoningLevel(stringModelValue(entry, "default_reasoning_level"))
|
||||
if _, ok := allowedDefaults[defaultLevel]; !ok {
|
||||
defaultLevel = stringModelValue(levels[0].(map[string]any), "effort")
|
||||
}
|
||||
|
||||
entry["supported_reasoning_levels"] = levels
|
||||
entry["default_reasoning_level"] = defaultLevel
|
||||
}
|
||||
|
||||
func normalizeCodexClientReasoningLevel(rawLevel string) string {
|
||||
level := strings.ToLower(strings.TrimSpace(rawLevel))
|
||||
if _, ok := codexClientAllowedReasoningLevels[level]; !ok {
|
||||
return ""
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func codexClientReasoningDescription(level string) string {
|
||||
switch level {
|
||||
case "none":
|
||||
return "No reasoning"
|
||||
case "minimal":
|
||||
return "Fastest responses with minimal reasoning"
|
||||
case "low":
|
||||
return "Fast responses with lighter reasoning"
|
||||
case "medium":
|
||||
return "Balances speed and reasoning depth for everyday tasks"
|
||||
case "high":
|
||||
return "Greater reasoning depth for complex problems"
|
||||
case "xhigh":
|
||||
return "Extra high reasoning depth for complex problems"
|
||||
case "max":
|
||||
return "Maximum available reasoning depth for complex problems"
|
||||
default:
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
func codexClientModelPriority(model map[string]any) int {
|
||||
if priority, ok := model["priority"].(int); ok {
|
||||
return priority
|
||||
}
|
||||
if priority, ok := model["priority"].(float64); ok {
|
||||
return int(priority)
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
func stringModelValue(model map[string]any, key string) string {
|
||||
if model == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := model[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intModelValue(model map[string]any, key string) int {
|
||||
if model == nil {
|
||||
return 0
|
||||
}
|
||||
switch value := model[key].(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCodexClientModelMap(model map[string]any) map[string]any {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]any, len(model))
|
||||
for key, value := range model {
|
||||
cloned[key] = cloneCodexClientModelValue(value)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCodexClientModelValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneCodexClientModelMap(typed)
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for i, entry := range typed {
|
||||
cloned[i] = cloneCodexClientModelValue(entry)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
400
backend/internal/client/codex/models/models_test.go
Normal file
400
backend/internal/client/codex/models/models_test.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) {
|
||||
modelID := "mimo-v2.5-pro-codex-test"
|
||||
textOnlyModelID := "mimo-text-only-codex-test"
|
||||
modelRegistry := registry.GetGlobalRegistry()
|
||||
modelRegistry.RegisterClient("codex-input-modalities-test", "openai-compatibility", []*registry.ModelInfo{
|
||||
{
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
OwnedBy: "mimo",
|
||||
Type: "openai-compatibility",
|
||||
DisplayName: modelID,
|
||||
SupportedInputModalities: []string{"text", "image"},
|
||||
},
|
||||
{
|
||||
ID: textOnlyModelID,
|
||||
Object: "model",
|
||||
OwnedBy: "mimo",
|
||||
Type: "openai-compatibility",
|
||||
DisplayName: textOnlyModelID,
|
||||
SupportedInputModalities: []string{"text"},
|
||||
},
|
||||
{
|
||||
ID: "mimo-mixed-modalities-codex-test",
|
||||
Object: "model",
|
||||
OwnedBy: "mimo",
|
||||
Type: "openai-compatibility",
|
||||
DisplayName: "mimo-mixed-modalities-codex-test",
|
||||
SupportedInputModalities: []string{"text", "image", "audio", "video", "TEXT", "IMAGE"},
|
||||
},
|
||||
{
|
||||
ID: "compat-image-only-codex-test",
|
||||
Object: "model",
|
||||
OwnedBy: "mimo",
|
||||
Type: registry.OpenAIImageModelType,
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
modelRegistry.UnregisterClient("codex-input-modalities-test")
|
||||
})
|
||||
|
||||
openaiModels := modelRegistry.GetAvailableModels("openai")
|
||||
resp := BuildResponse(openaiModels, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
var visionEntry map[string]any
|
||||
var textOnlyEntry map[string]any
|
||||
var mixedEntry map[string]any
|
||||
var imageEntry map[string]any
|
||||
for _, entry := range models {
|
||||
slug := stringModelValue(entry, "slug")
|
||||
switch slug {
|
||||
case modelID:
|
||||
visionEntry = entry
|
||||
case textOnlyModelID:
|
||||
textOnlyEntry = entry
|
||||
case "mimo-mixed-modalities-codex-test":
|
||||
mixedEntry = entry
|
||||
case "compat-image-only-codex-test":
|
||||
imageEntry = entry
|
||||
}
|
||||
}
|
||||
if visionEntry == nil {
|
||||
t.Fatalf("expected codex entry for %q", modelID)
|
||||
}
|
||||
modalities, ok := visionEntry["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 2 {
|
||||
t.Fatalf("input_modalities = %#v, want [text image]", visionEntry["input_modalities"])
|
||||
}
|
||||
if got, _ := modalities[0].(string); got != "text" {
|
||||
t.Fatalf("input_modalities[0] = %q, want text", got)
|
||||
}
|
||||
if got, _ := modalities[1].(string); got != "image" {
|
||||
t.Fatalf("input_modalities[1] = %q, want image", got)
|
||||
}
|
||||
if got, ok := visionEntry["supports_image_detail_original"].(bool); !ok || !got {
|
||||
t.Fatalf("supports_image_detail_original = %#v, want true", visionEntry["supports_image_detail_original"])
|
||||
}
|
||||
|
||||
if textOnlyEntry == nil {
|
||||
t.Fatalf("expected codex entry for %q", textOnlyModelID)
|
||||
}
|
||||
textOnlyModalities, ok := textOnlyEntry["input_modalities"].([]any)
|
||||
if !ok || len(textOnlyModalities) != 1 {
|
||||
t.Fatalf("text-only input_modalities = %#v, want [text]", textOnlyEntry["input_modalities"])
|
||||
}
|
||||
if got, _ := textOnlyModalities[0].(string); got != "text" {
|
||||
t.Fatalf("text-only input_modalities[0] = %q, want text", got)
|
||||
}
|
||||
if _, exists := textOnlyEntry["supports_image_detail_original"]; exists {
|
||||
t.Fatalf("text-only model should not expose supports_image_detail_original: %#v", textOnlyEntry["supports_image_detail_original"])
|
||||
}
|
||||
|
||||
if mixedEntry == nil {
|
||||
t.Fatal("expected codex entry for mixed-modalities model")
|
||||
}
|
||||
mixedModalities, ok := mixedEntry["input_modalities"].([]any)
|
||||
if !ok || len(mixedModalities) != 2 {
|
||||
t.Fatalf("mixed input_modalities = %#v, want [text image]", mixedEntry["input_modalities"])
|
||||
}
|
||||
if got, _ := mixedModalities[0].(string); got != "text" {
|
||||
t.Fatalf("mixed input_modalities[0] = %q, want text", got)
|
||||
}
|
||||
if got, _ := mixedModalities[1].(string); got != "image" {
|
||||
t.Fatalf("mixed input_modalities[1] = %q, want image", got)
|
||||
}
|
||||
if got, ok := mixedEntry["supports_image_detail_original"].(bool); !ok || !got {
|
||||
t.Fatalf("mixed supports_image_detail_original = %#v, want true", mixedEntry["supports_image_detail_original"])
|
||||
}
|
||||
|
||||
if imageEntry == nil {
|
||||
t.Fatal("expected codex entry for image-only compat model")
|
||||
}
|
||||
if got, _ := imageEntry["visibility"].(string); got != "hide" {
|
||||
t.Fatalf("image model visibility = %q, want hide", got)
|
||||
}
|
||||
if _, exists := imageEntry["input_modalities"]; exists {
|
||||
t.Fatalf("image endpoint model should not expose input_modalities from registry: %#v", imageEntry["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponse_AppliesDisplayNameToTemplateModel(t *testing.T) {
|
||||
resp := BuildResponse([]map[string]any{{
|
||||
"id": "gpt-5.5",
|
||||
"display_name": "Configured Codex Name",
|
||||
}}, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok || len(models) != 1 {
|
||||
t.Fatalf("models = %#v, want one model", resp["models"])
|
||||
}
|
||||
if got := stringModelValue(models[0], "display_name"); got != "Configured Codex Name" {
|
||||
t.Fatalf("display_name = %q, want Configured Codex Name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponse_RewritesTemplateMultiAgentVersionWhenEnabled(t *testing.T) {
|
||||
modelIDs := []string{"gpt-5.6-luna", "gpt-5.5"}
|
||||
resp := BuildResponse([]map[string]any{{"id": modelIDs[0]}, {"id": modelIDs[1]}}, nil, true)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if got := stringModelValue(model, "multi_agent_version"); got != "v2" {
|
||||
t.Errorf("%s multi_agent_version = %q, want v2", stringModelValue(model, "slug"), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponse_DisablesSearchToolForSynthesizedModels(t *testing.T) {
|
||||
resp := BuildResponse([]map[string]any{
|
||||
{"id": "custom-openai-compatible-model"},
|
||||
{"id": "gpt-5.5"},
|
||||
}, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
bySlug[stringModelValue(model, "slug")] = model
|
||||
}
|
||||
|
||||
custom := bySlug["custom-openai-compatible-model"]
|
||||
if custom == nil {
|
||||
t.Fatal("expected synthesized custom model entry")
|
||||
}
|
||||
if got, ok := custom["supports_search_tool"].(bool); !ok || got {
|
||||
t.Fatalf("custom supports_search_tool = %#v, want false", custom["supports_search_tool"])
|
||||
}
|
||||
|
||||
official := bySlug["gpt-5.5"]
|
||||
if official == nil {
|
||||
t.Fatal("expected official template model entry")
|
||||
}
|
||||
if got, ok := official["supports_search_tool"].(bool); !ok || !got {
|
||||
t.Fatalf("official supports_search_tool = %#v, want true", official["supports_search_tool"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponse_RequiresTemplateAndCodexProvidersForSearchTool(t *testing.T) {
|
||||
providers := map[string][]string{
|
||||
"new-codex-model": {"codex"},
|
||||
"gpt-5.5": {"openai-compatible-deepseek"},
|
||||
"gpt-5.4": {"codex", "xai"},
|
||||
"gpt-5.6-sol": {"codex"},
|
||||
}
|
||||
resp := BuildResponse([]map[string]any{
|
||||
{"id": "new-codex-model"},
|
||||
{"id": "gpt-5.5"},
|
||||
{"id": "gpt-5.4"},
|
||||
{"id": "gpt-5.6-sol"},
|
||||
}, func(id string) []string {
|
||||
return providers[id]
|
||||
}, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
bySlug[stringModelValue(model, "slug")] = model
|
||||
}
|
||||
|
||||
if got, ok := bySlug["gpt-5.6-sol"]["supports_search_tool"].(bool); !ok || !got {
|
||||
t.Errorf("gpt-5.6-sol supports_search_tool = %#v, want true", bySlug["gpt-5.6-sol"]["supports_search_tool"])
|
||||
}
|
||||
for _, slug := range []string{"new-codex-model", "gpt-5.5", "gpt-5.4"} {
|
||||
if got, ok := bySlug[slug]["supports_search_tool"].(bool); !ok || got {
|
||||
t.Errorf("%s supports_search_tool = %#v, want false", slug, bySlug[slug]["supports_search_tool"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponse_PreservesUltraReasoningEffort(t *testing.T) {
|
||||
resp := BuildResponse([]map[string]any{{"id": "gpt-5.6-sol"}}, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
var sol map[string]any
|
||||
for _, entry := range models {
|
||||
if stringModelValue(entry, "slug") == "gpt-5.6-sol" {
|
||||
sol = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if sol == nil {
|
||||
t.Fatal("expected codex client entry for gpt-5.6-sol")
|
||||
}
|
||||
|
||||
levels, ok := sol["supported_reasoning_levels"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("supported_reasoning_levels = %T, want []any", sol["supported_reasoning_levels"])
|
||||
}
|
||||
for _, rawLevel := range levels {
|
||||
level, ok := rawLevel.(map[string]any)
|
||||
if ok && stringModelValue(level, "effort") == "ultra" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("supported_reasoning_levels = %#v, want ultra", levels)
|
||||
}
|
||||
|
||||
func TestLoadCodexClientModelTemplatesRefreshesOnRevision(t *testing.T) {
|
||||
codexClientModelTemplatesMu.Lock()
|
||||
previousLoaded := codexClientModelTemplatesLoaded
|
||||
previousRevision := codexClientModelTemplatesRevision
|
||||
previousTemplates := codexClientModelTemplates
|
||||
previousDefault := codexClientDefaultTemplate
|
||||
previousErr := codexClientModelTemplatesErr
|
||||
codexClientModelTemplatesLoaded = false
|
||||
codexClientModelTemplatesMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
codexClientModelTemplatesMu.Lock()
|
||||
codexClientModelTemplatesLoaded = previousLoaded
|
||||
codexClientModelTemplatesRevision = previousRevision
|
||||
codexClientModelTemplates = previousTemplates
|
||||
codexClientDefaultTemplate = previousDefault
|
||||
codexClientModelTemplatesErr = previousErr
|
||||
codexClientModelTemplatesMu.Unlock()
|
||||
})
|
||||
|
||||
first := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"First"}]}`)
|
||||
templates, defaultTemplate, err := loadCodexClientModelTemplatesSnapshot(first, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("load first snapshot: %v", err)
|
||||
}
|
||||
if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "First" {
|
||||
t.Fatalf("first display_name = %q, want First", got)
|
||||
}
|
||||
if got := stringModelValue(defaultTemplate, "display_name"); got != "First" {
|
||||
t.Fatalf("first default display_name = %q, want First", got)
|
||||
}
|
||||
|
||||
second := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"Second"}]}`)
|
||||
templates, defaultTemplate, err = loadCodexClientModelTemplatesSnapshot(second, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("load second snapshot: %v", err)
|
||||
}
|
||||
if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" {
|
||||
t.Fatalf("second display_name = %q, want Second", got)
|
||||
}
|
||||
if got := stringModelValue(defaultTemplate, "display_name"); got != "Second" {
|
||||
t.Fatalf("second default display_name = %q, want Second", got)
|
||||
}
|
||||
|
||||
templates, _, err = loadCodexClientModelTemplatesSnapshot(first, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("reload cached revision: %v", err)
|
||||
}
|
||||
if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" {
|
||||
t.Fatalf("cached display_name = %q, want Second", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCodexClientModelMetadataPreservesMultiAgentVersionWhenDisabled(t *testing.T) {
|
||||
entry := map[string]any{"multi_agent_version": "v1"}
|
||||
model := map[string]any{"id": "custom-model"}
|
||||
|
||||
applyCodexClientModelMetadata(entry, "custom-model", model, false)
|
||||
if got := entry["multi_agent_version"]; got != "v1" {
|
||||
t.Fatalf("disabled multi_agent_version = %#v, want preserved v1", got)
|
||||
}
|
||||
|
||||
applyCodexClientModelMetadata(entry, "custom-model", model, true)
|
||||
if got := entry["multi_agent_version"]; got != "v2" {
|
||||
t.Fatalf("enabled multi_agent_version = %#v, want v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponseAppliesMaxContextLengthOverride(t *testing.T) {
|
||||
const wantOverride = 1048576
|
||||
const wantDefault = 272000
|
||||
|
||||
resp := BuildResponse([]map[string]any{
|
||||
{"id": "deepseek-v4-flash", "max_context_length": wantOverride},
|
||||
{"id": "deepseek-v4-pro"},
|
||||
{"id": "gpt-5.5", "max_context_length": wantOverride},
|
||||
}, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
bySlug[stringModelValue(model, "slug")] = model
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
slug string
|
||||
want int
|
||||
}{
|
||||
{slug: "deepseek-v4-flash", want: wantOverride},
|
||||
{slug: "deepseek-v4-pro", want: wantDefault},
|
||||
{slug: "gpt-5.5", want: wantOverride},
|
||||
} {
|
||||
entry := bySlug[testCase.slug]
|
||||
if entry == nil {
|
||||
t.Fatalf("missing model %q", testCase.slug)
|
||||
}
|
||||
if got := intModelValue(entry, "context_window"); got != testCase.want {
|
||||
t.Errorf("%s context_window = %d, want %d", testCase.slug, got, testCase.want)
|
||||
}
|
||||
if got := intModelValue(entry, "max_context_window"); got != testCase.want {
|
||||
t.Errorf("%s max_context_window = %d, want %d", testCase.slug, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexClientModelsResponseMapsMaxCompletionTokensToMaxTokens(t *testing.T) {
|
||||
const wantTemplateLimit = 64000
|
||||
const wantSynthesizedLimit = 32000
|
||||
|
||||
resp := BuildResponse([]map[string]any{
|
||||
{"id": "gpt-5.5", "max_completion_tokens": wantTemplateLimit},
|
||||
{"id": "custom-output-limit-model", "max_completion_tokens": wantSynthesizedLimit},
|
||||
}, nil, false)
|
||||
models, ok := resp["models"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models type = %T, want []map[string]any", resp["models"])
|
||||
}
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
bySlug[stringModelValue(model, "slug")] = model
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
slug string
|
||||
want int
|
||||
}{
|
||||
{slug: "gpt-5.5", want: wantTemplateLimit},
|
||||
{slug: "custom-output-limit-model", want: wantSynthesizedLimit},
|
||||
} {
|
||||
entry := bySlug[testCase.slug]
|
||||
if entry == nil {
|
||||
t.Fatalf("missing model %q", testCase.slug)
|
||||
}
|
||||
if got := intModelValue(entry, "max_tokens"); got != testCase.want {
|
||||
t.Errorf("%s max_tokens = %d, want %d", testCase.slug, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,988 @@
|
|||
package multiagentv2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
codexSpawnAgentDescriptionMarker = "Spawns an agent"
|
||||
codexSpawnAgentModelsHeading = "Available model overrides (optional; inherited parent model is preferred):"
|
||||
codexCollaborationNamespace = "collaboration"
|
||||
codexOptimizedCollaborationNamespace = "collaboration-optimize"
|
||||
codexOptimizedCollaborationNamePrefix = codexOptimizedCollaborationNamespace + "__"
|
||||
)
|
||||
|
||||
// CodexMultiAgentV2ToolsPreparedContextKey marks a request whose collaboration
|
||||
// tool definitions were prepared at the Responses API boundary.
|
||||
const CodexMultiAgentV2ToolsPreparedContextKey = "codex_multi_agent_v2_tools_prepared"
|
||||
|
||||
// codexCollaborationMessageTools are the collaboration tool names whose
|
||||
// parameters.properties.message.encrypted field must be stripped so that
|
||||
// message content remains readable by the proxy.
|
||||
var codexCollaborationMessageTools = map[string]struct{}{
|
||||
"spawn_agent": {},
|
||||
"send_message": {},
|
||||
"followup_task": {},
|
||||
}
|
||||
|
||||
type codexSpawnAgentModel struct {
|
||||
id string
|
||||
description string
|
||||
reasoningEfforts []string
|
||||
defaultReasoningEffort string
|
||||
serviceTiers []string
|
||||
priority int
|
||||
displayName string
|
||||
}
|
||||
|
||||
type codexClientModelsCatalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
|
||||
// RewriteCodexSpawnAgentDescription optimizes spawn_agent definitions for
|
||||
// official Codex clients when multi-agent v2 optimization is enabled.
|
||||
func RewriteCodexSpawnAgentDescription(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte {
|
||||
updated, _ := OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg)
|
||||
return updated
|
||||
}
|
||||
|
||||
// RewriteCodexMultiAgentV2Input converts official Codex multi-agent input into
|
||||
// standard Responses API messages when multi-agent v2 optimization is enabled.
|
||||
func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte {
|
||||
if !codexMultiAgentV2Enabled(ctx, headers, cfg) {
|
||||
return payload
|
||||
}
|
||||
return rewriteCodexAgentMessageInput(payload)
|
||||
}
|
||||
|
||||
// TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
|
||||
// input before translating it to a non-Codex target protocol.
|
||||
func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte {
|
||||
if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
|
||||
payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
|
||||
}
|
||||
return sdktranslator.TranslateRequest(from, to, model, payload, stream)
|
||||
}
|
||||
|
||||
// PrepareCodexMultiAgentV2Tools prepares collaboration tool definitions at the
|
||||
// Responses API boundary without changing the collaboration namespace.
|
||||
func PrepareCodexMultiAgentV2Tools(ctx context.Context, headers http.Header, payload []byte, enabled, homeEnabled bool) ([]byte, bool) {
|
||||
if !codexMultiAgentV2ClientEnabled(ctx, headers, enabled) {
|
||||
return payload, false
|
||||
}
|
||||
|
||||
toolPaths := codexSpawnAgentToolPaths(payload)
|
||||
messageToolPaths := codexCollaborationMessageToolPaths(payload)
|
||||
if len(toolPaths) == 0 && len(messageToolPaths) == 0 {
|
||||
return payload, true
|
||||
}
|
||||
if hasCodexOptimizedCollaborationConflict(payload) {
|
||||
return removeCodexCollaborationMessageEncryption(payload, messageToolPaths), true
|
||||
}
|
||||
|
||||
var models []codexSpawnAgentModel
|
||||
var formattedMarkdown string
|
||||
if len(toolPaths) > 0 {
|
||||
models, formattedMarkdown = codexSpawnAgentModelsAndMarkdownForRequest(ctx, headers, homeEnabled)
|
||||
}
|
||||
|
||||
updated := rewriteCodexCollaborationTools(payload, messageToolPaths, toolPaths, models, formattedMarkdown)
|
||||
return updated, true
|
||||
}
|
||||
|
||||
// OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and
|
||||
// reports whether the collaboration namespace was renamed for upstream use.
|
||||
func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) {
|
||||
if !codexMultiAgentV2Enabled(ctx, headers, cfg) {
|
||||
return payload, false
|
||||
}
|
||||
updated := rewriteCodexAgentMessageContent(payload)
|
||||
if codexMultiAgentV2ToolsPrepared(ctx) {
|
||||
updated = removeCodexCollaborationMessageEncryption(updated, codexCollaborationMessageToolPaths(updated))
|
||||
} else {
|
||||
updated, _ = PrepareCodexMultiAgentV2Tools(ctx, headers, updated, cfg.Codex.OptimizeMultiAgentV2, cfg.Home.Enabled)
|
||||
}
|
||||
toolPaths := codexSpawnAgentToolPaths(updated)
|
||||
if len(toolPaths) == 0 || hasCodexOptimizedCollaborationConflict(updated) {
|
||||
return updated, false
|
||||
}
|
||||
return optimizeCodexCollaborationNamespace(updated, toolPaths)
|
||||
}
|
||||
|
||||
func codexMultiAgentV2Enabled(ctx context.Context, headers http.Header, cfg *config.Config) bool {
|
||||
return cfg != nil && codexMultiAgentV2ClientEnabled(ctx, headers, cfg.Codex.OptimizeMultiAgentV2)
|
||||
}
|
||||
|
||||
func codexMultiAgentV2ClientEnabled(ctx context.Context, headers http.Header, enabled bool) bool {
|
||||
return enabled && isCodexMultiAgentClient(codexClientUserAgent(ctx, headers))
|
||||
}
|
||||
|
||||
func codexMultiAgentV2ToolsPrepared(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
ginCtx, ok := ctx.Value("gin").(*gin.Context)
|
||||
if !ok || ginCtx == nil {
|
||||
return false
|
||||
}
|
||||
prepared, ok := ginCtx.Get(CodexMultiAgentV2ToolsPreparedContextKey)
|
||||
isPrepared, _ := prepared.(bool)
|
||||
return ok && isPrepared
|
||||
}
|
||||
|
||||
func codexClientUserAgent(ctx context.Context, headers http.Header) string {
|
||||
if ctx != nil {
|
||||
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
return headerValueCaseInsensitive(ginCtx.Request.Header, "User-Agent")
|
||||
}
|
||||
}
|
||||
return headerValueCaseInsensitive(headers, "User-Agent")
|
||||
}
|
||||
|
||||
func headerValueCaseInsensitive(headers http.Header, name string) string {
|
||||
if headers == nil {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
for key, values := range headers {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsCodexClientUserAgent reports whether a request uses an official Codex client identity.
|
||||
func IsCodexClientUserAgent(userAgent string) bool {
|
||||
userAgent = strings.TrimSpace(userAgent)
|
||||
return strings.HasPrefix(userAgent, "Codex Desktop/") ||
|
||||
strings.HasPrefix(userAgent, "codex-tui/") ||
|
||||
userAgent == "codex_cli_rs" ||
|
||||
strings.HasPrefix(userAgent, "codex_cli_rs/")
|
||||
}
|
||||
|
||||
func isCodexMultiAgentClient(userAgent string) bool {
|
||||
return IsCodexClientUserAgent(userAgent)
|
||||
}
|
||||
|
||||
var (
|
||||
codexCatalogTemplatesMu sync.RWMutex
|
||||
codexCatalogTemplatesLoaded bool
|
||||
codexCatalogTemplatesRevision uint64
|
||||
codexCatalogTemplates map[string]map[string]any
|
||||
codexCatalogDefaultTemplate map[string]any
|
||||
|
||||
codexSpawnAgentCacheMu sync.RWMutex
|
||||
codexSpawnAgentCacheRevision uint64
|
||||
codexSpawnAgentCacheGeneration uint64
|
||||
codexSpawnAgentCachedModels []codexSpawnAgentModel
|
||||
codexSpawnAgentCachedMarkdown string
|
||||
)
|
||||
|
||||
func loadCodexCatalogTemplates() (map[string]map[string]any, map[string]any, uint64, error) {
|
||||
currentRevision := registry.GetCodexClientModelsRevision()
|
||||
|
||||
codexCatalogTemplatesMu.RLock()
|
||||
if codexCatalogTemplatesLoaded && codexCatalogTemplatesRevision == currentRevision {
|
||||
templates := codexCatalogTemplates
|
||||
defaultTemplate := codexCatalogDefaultTemplate
|
||||
codexCatalogTemplatesMu.RUnlock()
|
||||
return templates, defaultTemplate, currentRevision, nil
|
||||
}
|
||||
codexCatalogTemplatesMu.RUnlock()
|
||||
|
||||
codexCatalogTemplatesMu.Lock()
|
||||
defer codexCatalogTemplatesMu.Unlock()
|
||||
if codexCatalogTemplatesLoaded && codexCatalogTemplatesRevision == currentRevision {
|
||||
return codexCatalogTemplates, codexCatalogDefaultTemplate, currentRevision, nil
|
||||
}
|
||||
|
||||
raw, revision := registry.GetCodexClientModelsSnapshot()
|
||||
|
||||
var catalog codexClientModelsCatalog
|
||||
errUnmarshal := json.Unmarshal(raw, &catalog)
|
||||
if errUnmarshal != nil || len(catalog.Models) == 0 {
|
||||
codexCatalogTemplatesLoaded = true
|
||||
codexCatalogTemplatesRevision = revision
|
||||
codexCatalogTemplates = nil
|
||||
codexCatalogDefaultTemplate = nil
|
||||
return nil, nil, revision, errUnmarshal
|
||||
}
|
||||
|
||||
templates := make(map[string]map[string]any, len(catalog.Models))
|
||||
var defaultTemplate map[string]any
|
||||
for _, model := range catalog.Models {
|
||||
modelID := mapString(model, "slug")
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
templates[modelID] = model
|
||||
if modelID == "gpt-5.5" {
|
||||
defaultTemplate = model
|
||||
}
|
||||
}
|
||||
|
||||
codexCatalogTemplatesLoaded = true
|
||||
codexCatalogTemplatesRevision = revision
|
||||
codexCatalogTemplates = templates
|
||||
codexCatalogDefaultTemplate = defaultTemplate
|
||||
return templates, defaultTemplate, revision, nil
|
||||
}
|
||||
|
||||
func codexSpawnAgentModelsAndMarkdownForRequest(ctx context.Context, headers http.Header, homeEnabled bool) ([]codexSpawnAgentModel, string) {
|
||||
if homeEnabled {
|
||||
availableModels := codexHomeAvailableModels(ctx, headers)
|
||||
templates, defaultTemplate, _, errLoad := loadCodexCatalogTemplates()
|
||||
if errLoad != nil || defaultTemplate == nil {
|
||||
return nil, ""
|
||||
}
|
||||
models := codexSpawnAgentModelsFromTemplates(availableModels, templates, defaultTemplate, func(modelID string) *registry.ModelInfo {
|
||||
return registry.LookupModelInfo(modelID)
|
||||
})
|
||||
formatted := formatCodexSpawnAgentModels(models)
|
||||
return models, formatted
|
||||
}
|
||||
|
||||
currentRevision := registry.GetCodexClientModelsRevision()
|
||||
currentGeneration := registry.GetGlobalRegistry().GetGeneration()
|
||||
|
||||
codexSpawnAgentCacheMu.RLock()
|
||||
if codexSpawnAgentCachedModels != nil && codexSpawnAgentCacheRevision == currentRevision && codexSpawnAgentCacheGeneration == currentGeneration {
|
||||
models := codexSpawnAgentCachedModels
|
||||
markdown := codexSpawnAgentCachedMarkdown
|
||||
codexSpawnAgentCacheMu.RUnlock()
|
||||
return models, markdown
|
||||
}
|
||||
codexSpawnAgentCacheMu.RUnlock()
|
||||
|
||||
templates, defaultTemplate, _, errLoad := loadCodexCatalogTemplates()
|
||||
if errLoad != nil || defaultTemplate == nil {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
availableModels := registry.GetGlobalRegistry().GetAvailableModels("openai")
|
||||
lookup := func(modelID string) *registry.ModelInfo {
|
||||
return registry.LookupModelInfo(modelID)
|
||||
}
|
||||
models := codexSpawnAgentModelsFromTemplates(availableModels, templates, defaultTemplate, lookup)
|
||||
formatted := formatCodexSpawnAgentModels(models)
|
||||
|
||||
codexSpawnAgentCacheMu.Lock()
|
||||
if currentRevision == registry.GetCodexClientModelsRevision() && currentGeneration == registry.GetGlobalRegistry().GetGeneration() {
|
||||
codexSpawnAgentCacheRevision = currentRevision
|
||||
codexSpawnAgentCacheGeneration = currentGeneration
|
||||
codexSpawnAgentCachedModels = models
|
||||
codexSpawnAgentCachedMarkdown = formatted
|
||||
}
|
||||
codexSpawnAgentCacheMu.Unlock()
|
||||
|
||||
return models, formatted
|
||||
}
|
||||
|
||||
func codexSpawnAgentModelsForRequest(ctx context.Context, headers http.Header, homeEnabled bool) []codexSpawnAgentModel {
|
||||
models, _ := codexSpawnAgentModelsAndMarkdownForRequest(ctx, headers, homeEnabled)
|
||||
return models
|
||||
}
|
||||
|
||||
func formatCodexSpawnAgentModelsForRequest(ctx context.Context, headers http.Header, homeEnabled bool) string {
|
||||
_, formatted := codexSpawnAgentModelsAndMarkdownForRequest(ctx, headers, homeEnabled)
|
||||
return formatted
|
||||
}
|
||||
|
||||
func codexHomeAvailableModels(ctx context.Context, headers http.Header) []map[string]any {
|
||||
client := home.Current()
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
requestHeaders := headers
|
||||
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
requestHeaders = ginCtx.Request.Header
|
||||
}
|
||||
query := make(url.Values)
|
||||
query.Set("client_version", "")
|
||||
raw, errGet := client.GetModels(ctx, requestHeaders, query)
|
||||
if errGet != nil {
|
||||
return nil
|
||||
}
|
||||
return decodeCodexHomeAvailableModels(raw)
|
||||
}
|
||||
|
||||
func decodeCodexHomeAvailableModels(raw []byte) []map[string]any {
|
||||
var sections map[string][]map[string]any
|
||||
if err := json.Unmarshal(raw, §ions); err != nil || len(sections) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]map[string]any, 0, 256)
|
||||
for _, sectionModels := range sections {
|
||||
for _, model := range sectionModels {
|
||||
modelID := mapString(model, "id")
|
||||
if modelID == "" {
|
||||
modelID = strings.TrimPrefix(mapString(model, "name"), "models/")
|
||||
}
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[modelID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[modelID] = struct{}{}
|
||||
|
||||
displayName := mapString(model, "display_name")
|
||||
if displayName == "" {
|
||||
displayName = mapString(model, "displayName")
|
||||
}
|
||||
entry := map[string]any{"id": modelID}
|
||||
if displayName != "" {
|
||||
entry["display_name"] = displayName
|
||||
entry["description"] = displayName
|
||||
}
|
||||
models = append(models, entry)
|
||||
}
|
||||
}
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
return mapString(models[i], "id") < mapString(models[j], "id")
|
||||
})
|
||||
return models
|
||||
}
|
||||
|
||||
func codexSpawnAgentModelsFromSources(availableModels []map[string]any, catalogJSON []byte, lookupModel func(string) *registry.ModelInfo) []codexSpawnAgentModel {
|
||||
var catalog codexClientModelsCatalog
|
||||
if err := json.Unmarshal(catalogJSON, &catalog); err != nil || len(catalog.Models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
templates := make(map[string]map[string]any, len(catalog.Models))
|
||||
var defaultTemplate map[string]any
|
||||
for _, model := range catalog.Models {
|
||||
modelID := mapString(model, "slug")
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
templates[modelID] = model
|
||||
if modelID == "gpt-5.5" {
|
||||
defaultTemplate = model
|
||||
}
|
||||
}
|
||||
if defaultTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return codexSpawnAgentModelsFromTemplates(availableModels, templates, defaultTemplate, lookupModel)
|
||||
}
|
||||
|
||||
func codexSpawnAgentModelsFromTemplates(availableModels []map[string]any, templates map[string]map[string]any, defaultTemplate map[string]any, lookupModel func(string) *registry.ModelInfo) []codexSpawnAgentModel {
|
||||
if defaultTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(availableModels))
|
||||
templateModels := make([]codexSpawnAgentModel, 0, len(availableModels))
|
||||
synthesizedModels := make([]codexSpawnAgentModel, 0, len(availableModels))
|
||||
for _, availableModel := range availableModels {
|
||||
modelID := mapString(availableModel, "id")
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[modelID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[modelID] = struct{}{}
|
||||
|
||||
if template, ok := templates[modelID]; ok {
|
||||
templateModels = append(templateModels, codexSpawnAgentModelFromMetadata(modelID, template))
|
||||
continue
|
||||
}
|
||||
|
||||
profile := codexSpawnAgentModelFromMetadata(modelID, defaultTemplate)
|
||||
profile.id = modelID
|
||||
profile.description = mapString(availableModel, "description")
|
||||
profile.displayName = mapString(availableModel, "display_name")
|
||||
if profile.displayName == "" {
|
||||
profile.displayName = modelID
|
||||
}
|
||||
if lookupModel != nil {
|
||||
if info := lookupModel(modelID); info != nil {
|
||||
if strings.TrimSpace(info.Description) != "" {
|
||||
profile.description = strings.TrimSpace(info.Description)
|
||||
}
|
||||
applyCodexSpawnAgentThinking(&profile, info.Thinking)
|
||||
}
|
||||
}
|
||||
if profile.description == "" {
|
||||
profile.description = modelID
|
||||
}
|
||||
profile.serviceTiers = nil
|
||||
synthesizedModels = append(synthesizedModels, profile)
|
||||
}
|
||||
|
||||
sort.SliceStable(templateModels, func(i, j int) bool {
|
||||
if templateModels[i].priority == templateModels[j].priority {
|
||||
return templateModels[i].id < templateModels[j].id
|
||||
}
|
||||
return templateModels[i].priority < templateModels[j].priority
|
||||
})
|
||||
sort.SliceStable(synthesizedModels, func(i, j int) bool {
|
||||
left := strings.ToLower(synthesizedModels[i].displayName)
|
||||
right := strings.ToLower(synthesizedModels[j].displayName)
|
||||
if left == right {
|
||||
return synthesizedModels[i].id < synthesizedModels[j].id
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
return append(templateModels, synthesizedModels...)
|
||||
}
|
||||
|
||||
func codexSpawnAgentModelFromMetadata(modelID string, metadata map[string]any) codexSpawnAgentModel {
|
||||
profile := codexSpawnAgentModel{
|
||||
id: modelID,
|
||||
description: mapString(metadata, "description"),
|
||||
displayName: mapString(metadata, "display_name"),
|
||||
priority: mapInt(metadata, "priority"),
|
||||
}
|
||||
profile.reasoningEfforts, profile.defaultReasoningEffort = codexReasoningMetadata(metadata)
|
||||
profile.serviceTiers = codexServiceTierIDs(metadata)
|
||||
return profile
|
||||
}
|
||||
|
||||
func applyCodexSpawnAgentThinking(profile *codexSpawnAgentModel, thinking *registry.ThinkingSupport) {
|
||||
if profile == nil || thinking == nil || len(thinking.Levels) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
efforts := make([]string, 0, len(thinking.Levels))
|
||||
defaultEffort := ""
|
||||
firstEffort := ""
|
||||
for _, rawEffort := range thinking.Levels {
|
||||
effort := normalizeCodexReasoningEffort(rawEffort)
|
||||
if effort == "" {
|
||||
continue
|
||||
}
|
||||
if firstEffort == "" {
|
||||
firstEffort = effort
|
||||
}
|
||||
if (defaultEffort == "" && effort != "none") || effort == "medium" {
|
||||
defaultEffort = effort
|
||||
}
|
||||
efforts = append(efforts, effort)
|
||||
}
|
||||
if len(efforts) == 0 {
|
||||
return
|
||||
}
|
||||
if defaultEffort == "" {
|
||||
defaultEffort = firstEffort
|
||||
}
|
||||
profile.reasoningEfforts = efforts
|
||||
profile.defaultReasoningEffort = defaultEffort
|
||||
}
|
||||
|
||||
func codexReasoningMetadata(metadata map[string]any) ([]string, string) {
|
||||
rawLevels, _ := metadata["supported_reasoning_levels"].([]any)
|
||||
efforts := make([]string, 0, len(rawLevels))
|
||||
allowed := make(map[string]struct{}, len(rawLevels))
|
||||
for _, rawLevel := range rawLevels {
|
||||
level, _ := rawLevel.(map[string]any)
|
||||
effort := normalizeCodexReasoningEffort(mapString(level, "effort"))
|
||||
if effort == "" {
|
||||
continue
|
||||
}
|
||||
efforts = append(efforts, effort)
|
||||
allowed[effort] = struct{}{}
|
||||
}
|
||||
if len(efforts) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
defaultEffort := normalizeCodexReasoningEffort(mapString(metadata, "default_reasoning_level"))
|
||||
if _, ok := allowed[defaultEffort]; !ok {
|
||||
defaultEffort = efforts[0]
|
||||
}
|
||||
return efforts, defaultEffort
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningEffort(effort string) string {
|
||||
effort = strings.ToLower(strings.TrimSpace(effort))
|
||||
switch effort {
|
||||
case "none", "low", "medium", "high", "xhigh", "max", "ultra":
|
||||
return effort
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func codexServiceTierIDs(metadata map[string]any) []string {
|
||||
rawTiers, _ := metadata["service_tiers"].([]any)
|
||||
tiers := make([]string, 0, len(rawTiers))
|
||||
seen := make(map[string]struct{}, len(rawTiers))
|
||||
for _, rawTier := range rawTiers {
|
||||
tier, _ := rawTier.(map[string]any)
|
||||
tierID := mapString(tier, "id")
|
||||
if tierID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[tierID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tierID] = struct{}{}
|
||||
tiers = append(tiers, tierID)
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
func mapString(values map[string]any, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := values[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func mapInt(values map[string]any, key string) int {
|
||||
if values == nil {
|
||||
return 0
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteCodexSpawnAgentDescription(payload []byte, models []codexSpawnAgentModel) []byte {
|
||||
return rewriteCodexSpawnAgentTools(payload, codexSpawnAgentToolPaths(payload), models)
|
||||
}
|
||||
|
||||
func rewriteCodexSpawnAgentTools(payload []byte, toolPaths []string, models []codexSpawnAgentModel) []byte {
|
||||
return rewriteCodexCollaborationTools(payload, toolPaths, toolPaths, models, "")
|
||||
}
|
||||
|
||||
func rewriteCodexCollaborationTools(payload []byte, messageToolPaths, spawnAgentToolPaths []string, models []codexSpawnAgentModel, modelList string) []byte {
|
||||
if len(messageToolPaths) == 0 && len(spawnAgentToolPaths) == 0 {
|
||||
return payload
|
||||
}
|
||||
if modelList == "" && len(models) > 0 {
|
||||
modelList = formatCodexSpawnAgentModels(models)
|
||||
}
|
||||
updated := payload
|
||||
for _, toolPath := range spawnAgentToolPaths {
|
||||
descriptionPath := toolPath + ".description"
|
||||
description := gjson.GetBytes(updated, descriptionPath)
|
||||
if description.Type == gjson.String && modelList != "" {
|
||||
rewritten := replaceCodexSpawnAgentModels(description.String(), modelList)
|
||||
if rewritten != description.String() {
|
||||
var errSet error
|
||||
updated, errSet = sjson.SetBytes(updated, descriptionPath, rewritten)
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, toolPath := range messageToolPaths {
|
||||
encryptedPath := toolPath + ".parameters.properties.message.encrypted"
|
||||
if gjson.GetBytes(updated, encryptedPath).Exists() {
|
||||
var errDelete error
|
||||
updated, errDelete = sjson.DeleteBytes(updated, encryptedPath)
|
||||
if errDelete != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// HasCodexMultiAgentV2NamespaceConflict reports whether the request defines
|
||||
// the reserved optimized namespace, which must remain untouched.
|
||||
func HasCodexMultiAgentV2NamespaceConflict(payload []byte) bool {
|
||||
return hasCodexOptimizedCollaborationConflict(payload)
|
||||
}
|
||||
|
||||
func hasCodexOptimizedCollaborationConflict(payload []byte) bool {
|
||||
if codexToolsHaveOptimizedCollaborationConflict(gjson.GetBytes(payload, "tools")) {
|
||||
return true
|
||||
}
|
||||
input := gjson.GetBytes(payload, "input")
|
||||
if !input.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, item := range input.Array() {
|
||||
if strings.TrimSpace(item.Get("type").String()) == "additional_tools" && codexToolsHaveOptimizedCollaborationConflict(item.Get("tools")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codexToolsHaveOptimizedCollaborationConflict(tools gjson.Result) bool {
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if name == codexOptimizedCollaborationNamespace || strings.HasPrefix(name, codexOptimizedCollaborationNamePrefix) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(tool.Get("type").String()) == "namespace" && codexToolsHaveOptimizedCollaborationConflict(tool.Get("tools")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func optimizeCodexCollaborationNamespace(payload []byte, toolPaths []string) ([]byte, bool) {
|
||||
updated := payload
|
||||
optimized := false
|
||||
for _, toolPath := range toolPaths {
|
||||
separatorIndex := strings.LastIndex(toolPath, ".tools.")
|
||||
if separatorIndex < 0 {
|
||||
continue
|
||||
}
|
||||
namespacePath := toolPath[:separatorIndex]
|
||||
namespace := gjson.GetBytes(updated, namespacePath)
|
||||
if strings.TrimSpace(namespace.Get("type").String()) != "namespace" || strings.TrimSpace(namespace.Get("name").String()) != codexCollaborationNamespace {
|
||||
continue
|
||||
}
|
||||
var errSet error
|
||||
updated, errSet = sjson.SetBytes(updated, namespacePath+".name", codexOptimizedCollaborationNamespace)
|
||||
if errSet != nil {
|
||||
return payload, false
|
||||
}
|
||||
optimized = true
|
||||
}
|
||||
return updated, optimized
|
||||
}
|
||||
|
||||
// RestoreCodexMultiAgentV2Response restores optimized collaboration namespace
|
||||
// values before an upstream response is translated and returned to the client.
|
||||
func RestoreCodexMultiAgentV2Response(payload []byte, optimized bool) []byte {
|
||||
if !optimized || len(payload) == 0 || !gjson.ValidBytes(payload) {
|
||||
return payload
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if errDecode := decoder.Decode(&value); errDecode != nil {
|
||||
return payload
|
||||
}
|
||||
if !restoreCodexCollaborationValue(value) {
|
||||
return payload
|
||||
}
|
||||
restored, errMarshal := json.Marshal(value)
|
||||
if errMarshal != nil {
|
||||
return payload
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func restoreCodexCollaborationValue(value any) bool {
|
||||
changed := false
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if restoreCodexCollaborationValue(item) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
itemType := strings.TrimSpace(mapString(typed, "type"))
|
||||
isToolCall := itemType == "function_call" || itemType == "custom_tool_call"
|
||||
if isToolCall {
|
||||
if namespace, ok := typed["namespace"].(string); ok && namespace == codexOptimizedCollaborationNamespace {
|
||||
typed["namespace"] = codexCollaborationNamespace
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if name, ok := typed["name"].(string); ok {
|
||||
switch {
|
||||
case name == codexOptimizedCollaborationNamespace && itemType == "namespace":
|
||||
typed["name"] = codexCollaborationNamespace
|
||||
changed = true
|
||||
case isToolCall && strings.HasPrefix(name, codexOptimizedCollaborationNamePrefix):
|
||||
typed["name"] = codexCollaborationNamespace + "__" + strings.TrimPrefix(name, codexOptimizedCollaborationNamePrefix)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for key, child := range typed {
|
||||
if key == "arguments" || key == "input" || key == "output" && (itemType == "function_call_output" || itemType == "custom_tool_call_output") {
|
||||
continue
|
||||
}
|
||||
if restoreCodexCollaborationValue(child) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func rewriteCodexAgentMessageInput(payload []byte) []byte {
|
||||
input := gjson.GetBytes(payload, "input")
|
||||
if !input.IsArray() {
|
||||
return payload
|
||||
}
|
||||
|
||||
updated := rewriteCodexAgentMessageContent(payload)
|
||||
for itemIndex, item := range input.Array() {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "agent_message" {
|
||||
continue
|
||||
}
|
||||
itemPath := fmt.Sprintf("input.%d", itemIndex)
|
||||
var errSet error
|
||||
updated, errSet = sjson.SetBytes(updated, itemPath+".role", "user")
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
updated, errSet = sjson.SetBytes(updated, itemPath+".type", "message")
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func rewriteCodexAgentMessageContent(payload []byte) []byte {
|
||||
input := gjson.GetBytes(payload, "input")
|
||||
if !input.IsArray() {
|
||||
return payload
|
||||
}
|
||||
|
||||
updated := payload
|
||||
for itemIndex, item := range input.Array() {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "agent_message" {
|
||||
continue
|
||||
}
|
||||
content := item.Get("content")
|
||||
if !content.IsArray() {
|
||||
continue
|
||||
}
|
||||
for partIndex, part := range content.Array() {
|
||||
if strings.TrimSpace(part.Get("type").String()) != "encrypted_content" {
|
||||
continue
|
||||
}
|
||||
encryptedContent := part.Get("encrypted_content")
|
||||
if encryptedContent.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
partPath := fmt.Sprintf("input.%d.content.%d", itemIndex, partIndex)
|
||||
var errSet error
|
||||
updated, errSet = sjson.SetBytes(updated, partPath+".type", "input_text")
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
updated, errSet = sjson.SetBytes(updated, partPath+".text", encryptedContent.String())
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
updated, errSet = sjson.DeleteBytes(updated, partPath+".encrypted_content")
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func codexSpawnAgentToolPaths(payload []byte) []string {
|
||||
return codexToolPathsByNames(payload, map[string]struct{}{"spawn_agent": {}})
|
||||
}
|
||||
|
||||
// codexCollaborationMessageToolPaths discovers function tools named
|
||||
// spawn_agent, send_message, or followup_task inside top-level tools arrays and
|
||||
// input[].additional_tools arrays, including nested namespace tools.
|
||||
func codexCollaborationMessageToolPaths(payload []byte) []string {
|
||||
return codexToolPathsByNames(payload, codexCollaborationMessageTools)
|
||||
}
|
||||
|
||||
func codexToolPathsByNames(payload []byte, names map[string]struct{}) []string {
|
||||
paths := make([]string, 0, len(names))
|
||||
collectCodexToolPathsByNames(gjson.GetBytes(payload, "tools"), "tools", &paths, names)
|
||||
|
||||
input := gjson.GetBytes(payload, "input")
|
||||
if input.IsArray() {
|
||||
for index, item := range input.Array() {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
|
||||
continue
|
||||
}
|
||||
collectCodexToolPathsByNames(item.Get("tools"), fmt.Sprintf("input.%d.tools", index), &paths, names)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func collectCodexToolPathsByNames(tools gjson.Result, path string, paths *[]string, names map[string]struct{}) {
|
||||
if !tools.IsArray() {
|
||||
return
|
||||
}
|
||||
for index, tool := range tools.Array() {
|
||||
toolPath := fmt.Sprintf("%s.%d", path, index)
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
if toolType == "function" {
|
||||
if _, ok := names[strings.TrimSpace(tool.Get("name").String())]; ok {
|
||||
*paths = append(*paths, toolPath)
|
||||
}
|
||||
}
|
||||
if toolType == "namespace" {
|
||||
collectCodexToolPathsByNames(tool.Get("tools"), toolPath+".tools", paths, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeCodexCollaborationMessageEncryption deletes the
|
||||
// parameters.properties.message.encrypted field from each discovered
|
||||
// collaboration message tool so the proxy can read the plaintext message.
|
||||
func removeCodexCollaborationMessageEncryption(payload []byte, toolPaths []string) []byte {
|
||||
updated := payload
|
||||
for _, toolPath := range toolPaths {
|
||||
encryptedPath := toolPath + ".parameters.properties.message.encrypted"
|
||||
if !gjson.GetBytes(updated, encryptedPath).Exists() {
|
||||
continue
|
||||
}
|
||||
var errDelete error
|
||||
updated, errDelete = sjson.DeleteBytes(updated, encryptedPath)
|
||||
if errDelete != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func formatCodexSpawnAgentModels(models []codexSpawnAgentModel) string {
|
||||
var modelList strings.Builder
|
||||
for _, model := range models {
|
||||
modelID := strings.Join(strings.Fields(model.id), " ")
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
modelList.WriteString("- ")
|
||||
modelList.WriteString(markdownCode(modelID))
|
||||
modelList.WriteString(": ")
|
||||
hasDetails := false
|
||||
if description := strings.Join(strings.Fields(model.description), " "); description != "" {
|
||||
writeSentence(&modelList, description)
|
||||
hasDetails = true
|
||||
}
|
||||
if len(model.reasoningEfforts) > 0 {
|
||||
if hasDetails {
|
||||
modelList.WriteByte(' ')
|
||||
}
|
||||
modelList.WriteString("Reasoning efforts: ")
|
||||
for index, effort := range model.reasoningEfforts {
|
||||
if index > 0 {
|
||||
modelList.WriteString(", ")
|
||||
}
|
||||
modelList.WriteString(effort)
|
||||
if effort == model.defaultReasoningEffort {
|
||||
modelList.WriteString(" (default)")
|
||||
}
|
||||
}
|
||||
modelList.WriteByte('.')
|
||||
hasDetails = true
|
||||
}
|
||||
if len(model.serviceTiers) > 0 {
|
||||
if hasDetails {
|
||||
modelList.WriteByte(' ')
|
||||
}
|
||||
modelList.WriteString("Service tiers: ")
|
||||
modelList.WriteString(strings.Join(model.serviceTiers, ", "))
|
||||
modelList.WriteByte('.')
|
||||
}
|
||||
modelList.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimSuffix(modelList.String(), "\n")
|
||||
}
|
||||
|
||||
func markdownCode(value string) string {
|
||||
if strings.Contains(value, "`") {
|
||||
return "`` " + value + " ``"
|
||||
}
|
||||
return "`" + value + "`"
|
||||
}
|
||||
|
||||
func writeSentence(builder *strings.Builder, value string) {
|
||||
builder.WriteString(value)
|
||||
if !strings.ContainsAny(value[len(value)-1:], ".!?") {
|
||||
builder.WriteByte('.')
|
||||
}
|
||||
}
|
||||
|
||||
func replaceCodexSpawnAgentModels(description, modelList string) string {
|
||||
if modelList == "" {
|
||||
return description
|
||||
}
|
||||
|
||||
cleaned, headingIndent := removeCodexSpawnAgentModelSections(description)
|
||||
section := headingIndent + codexSpawnAgentModelsHeading + "\n" + modelList + "\n"
|
||||
markerIndex := strings.Index(cleaned, codexSpawnAgentDescriptionMarker)
|
||||
if markerIndex >= 0 {
|
||||
markerLineStart := strings.LastIndex(cleaned[:markerIndex], "\n") + 1
|
||||
return cleaned[:markerLineStart] + section + cleaned[markerLineStart:]
|
||||
}
|
||||
separator := ""
|
||||
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
||||
separator = "\n\n"
|
||||
}
|
||||
return cleaned + separator + strings.TrimSuffix(section, "\n")
|
||||
}
|
||||
|
||||
func removeCodexSpawnAgentModelSections(description string) (string, string) {
|
||||
if !strings.Contains(description, codexSpawnAgentModelsHeading) {
|
||||
return description, ""
|
||||
}
|
||||
lines := strings.SplitAfter(description, "\n")
|
||||
var cleaned strings.Builder
|
||||
headingIndent := ""
|
||||
for index := 0; index < len(lines); {
|
||||
line := lines[index]
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if trimmedLine != codexSpawnAgentModelsHeading {
|
||||
cleaned.WriteString(line)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if headingIndent == "" {
|
||||
headingIndex := strings.Index(line, codexSpawnAgentModelsHeading)
|
||||
if headingIndex > 0 {
|
||||
headingIndent = line[:headingIndex]
|
||||
}
|
||||
}
|
||||
index++
|
||||
for index < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[index]), "- ") {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return cleaned.String(), headingIndent
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue