package executor import ( "context" "errors" "fmt" "net" "net/http" "net/url" "strings" "time" "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "github.com/tidwall/sjson" "golang.org/x/net/proxy" ) const ( codexResponsesWebsocketBetaHeaderValue = "responses_websockets=2026-02-06" codexResponsesWebsocketIdleTimeout = 5 * time.Minute codexResponsesWebsocketHandshakeTO = 30 * time.Second ) func (e *CodexWebsocketsExecutor) dialCodexWebsocket(ctx context.Context, auth *cliproxyauth.Auth, wsURL string, headers http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error) { dialer := newProxyAwareWebsocketDialer(e.cfg, auth) dialer.HandshakeTimeout = codexResponsesWebsocketHandshakeTO dialer.EnableCompression = true if ctx == nil { ctx = context.Background() } conn, resp, err := dialer.DialContext(ctx, wsURL, headers) closer := newWebsocketConnectionCloser(conn) if conn != nil { // Avoid gorilla/websocket flate tail validation issues on some upstreams/Go versions. // Negotiating permessage-deflate is fine; we just don't compress outbound messages. conn.EnableWriteCompression(false) } return conn, closer, resp, err } func writeCodexWebsocketMessage(sess *codexWebsocketSession, conn *websocket.Conn, payload []byte) error { if sess != nil { return sess.writeMessage(conn, websocket.TextMessage, payload) } if conn == nil { return fmt.Errorf("codex websockets executor: websocket conn is nil") } return conn.WriteMessage(websocket.TextMessage, payload) } func mapCodexWebsocketWriteError(sess *codexWebsocketSession, conn *websocket.Conn, err error) error { if err == nil || sess == nil || conn == nil { return err } upstreamErr := sess.upstreamDisconnectError(conn) var closeErr *websocket.CloseError if !errors.As(upstreamErr, &closeErr) || closeErr.Code != websocket.CloseMessageTooBig { return err } return mapCodexWebsocketReadError(upstreamErr) } func shouldRetryCodexWebsocketSend(err error) bool { if err == nil { return false } var requestErr cliproxyexecutor.RequestScopedError return !errors.As(err, &requestErr) || !requestErr.IsRequestScoped() } type codexWebsocketMessageTooBigError struct { statusErr } func (codexWebsocketMessageTooBigError) IsRequestScoped() bool { return true } func mapCodexWebsocketReadError(err error) error { if err == nil { return nil } var closeErr *websocket.CloseError if errors.As(err, &closeErr) && closeErr.Code == websocket.CloseMessageTooBig { return codexWebsocketMessageTooBigError{statusErr: statusErr{ code: http.StatusRequestEntityTooLarge, msg: `{"error":{"message":"upstream websocket message too big","type":"invalid_request_error","code":"message_too_big"}}`, }} } return err } func normalizeCodexWebsocketParallelToolCalls(body []byte, headers http.Header) []byte { if !isCodexResponsesLiteRequest(body, headers) { return body } body = helps.SetBoolIfDifferent(body, "parallel_tool_calls", false) return body } func buildCodexWebsocketRequestBody(body []byte) []byte { if len(body) == 0 { return nil } // Match codex-rs websocket v2 semantics: every request is `response.create`. // Incremental follow-up turns continue on the same websocket using // `previous_response_id` + incremental `input`, not `response.append`. body = helps.SanitizeCodexInputItemIDs(body) wsReqBody, errSet := sjson.SetBytes(body, "type", "response.create") if errSet == nil && len(wsReqBody) > 0 { return wsReqBody } return body } func readCodexWebsocketMessage(ctx context.Context, sess *codexWebsocketSession, conn *websocket.Conn, readCh chan codexWebsocketRead) (int, []byte, error) { if sess == nil { if conn == nil { return 0, nil, fmt.Errorf("codex websockets executor: websocket conn is nil") } _ = conn.SetReadDeadline(time.Now().Add(codexResponsesWebsocketIdleTimeout)) msgType, payload, errRead := conn.ReadMessage() return msgType, payload, errRead } if conn == nil { return 0, nil, fmt.Errorf("codex websockets executor: websocket conn is nil") } if readCh == nil { return 0, nil, fmt.Errorf("codex websockets executor: session read channel is nil") } for { select { case <-ctx.Done(): return 0, nil, ctx.Err() case ev, ok := <-readCh: if !ok { return 0, nil, fmt.Errorf("codex websockets executor: session read channel closed") } if ev.conn != conn { continue } if ev.err != nil { return 0, nil, ev.err } return ev.msgType, ev.payload, nil } } } func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *websocket.Dialer { dialer := &websocket.Dialer{ Proxy: http.ProxyFromEnvironment, HandshakeTimeout: codexResponsesWebsocketHandshakeTO, EnableCompression: true, NetDialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, } proxyURL := "" if auth != nil { proxyURL = strings.TrimSpace(auth.ProxyURL) } if proxyURL == "" && cfg != nil { proxyURL = strings.TrimSpace(cfg.ProxyURL) } if proxyURL == "" { return dialer } setting, errParse := proxyutil.Parse(proxyURL) if errParse != nil { log.Errorf("codex websockets executor: %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 *proxy.Auth if setting.URL.User != nil { username := setting.URL.User.Username() password, _ := setting.URL.User.Password() proxyAuth = &proxy.Auth{User: username, Password: password} } socksDialer, errSOCKS5 := proxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, proxy.Direct) if errSOCKS5 != nil { log.Errorf("codex websockets executor: create SOCKS5 dialer failed: %v", errSOCKS5) return dialer } dialer.Proxy = nil dialer.NetDialContext = func(_ context.Context, network, addr string) (net.Conn, error) { return socksDialer.Dial(network, addr) } case "http", "https": dialer.Proxy = http.ProxyURL(setting.URL) default: log.Errorf("codex websockets executor: unsupported proxy scheme: %s", setting.URL.Scheme) } return dialer } func buildCodexResponsesWebsocketURL(httpURL string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(httpURL)) if err != nil { return "", err } switch strings.ToLower(parsed.Scheme) { case "http": parsed.Scheme = "ws" case "https": parsed.Scheme = "wss" default: return "", fmt.Errorf("codex websockets executor: unsupported responses websocket URL scheme %q", parsed.Scheme) } if strings.TrimSpace(parsed.Host) == "" { return "", fmt.Errorf("codex websockets executor: responses websocket URL host is empty") } return parsed.String(), nil }