diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 5e95cb1dc..3766900e0 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -156,7 +156,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } - httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } @@ -260,7 +260,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r AuthValue: authValue, }) - httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { @@ -437,7 +437,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A AuthValue: authValue, }) - httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { @@ -674,7 +674,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut AuthValue: authValue, }) - httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) resp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 2b243db8a..399368125 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -744,7 +744,7 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } @@ -821,7 +821,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re AuthType: authType, AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { @@ -987,7 +987,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A AuthType: authType, AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { @@ -1097,7 +1097,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 3c17dc63c..ad3315c66 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -1,6 +1,7 @@ package helps import ( + "context" "net" "net/http" "strings" @@ -128,21 +129,23 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return resp, nil } -// anthropicHosts contains the hosts that should use utls Chrome TLS fingerprint. -var anthropicHosts = map[string]struct{}{ +// utlsProtectedHosts contains the hosts that should use utls Chrome TLS fingerprint +// to bypass Cloudflare's TLS fingerprinting. +var utlsProtectedHosts = map[string]struct{}{ "api.anthropic.com": {}, + "chatgpt.com": {}, } -// fallbackRoundTripper uses utls for Anthropic HTTPS hosts and falls back to -// standard transport for all other requests (non-HTTPS or non-Anthropic hosts). +// fallbackRoundTripper uses utls for protected HTTPS hosts and falls back to +// standard transport for all other requests. type fallbackRoundTripper struct { - utls *utlsRoundTripper + utls http.RoundTripper fallback http.RoundTripper } func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Scheme == "https" { - if _, ok := anthropicHosts[strings.ToLower(req.URL.Hostname())]; ok { + if _, ok := utlsProtectedHosts[strings.ToLower(req.URL.Hostname())]; ok { return f.utls.RoundTrip(req) } } @@ -150,9 +153,9 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err } // NewUtlsHTTPClient creates an HTTP client using utls Chrome TLS fingerprint. -// Use this for Claude API requests to match real Claude Code's TLS behavior. +// Use this for provider requests that need a Chrome-like TLS fingerprint. // Falls back to standard transport for non-HTTPS requests. -func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { +func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { var proxyURL string if auth != nil { proxyURL = strings.TrimSpace(auth.ProxyURL) @@ -161,18 +164,20 @@ func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time proxyURL = strings.TrimSpace(cfg.ProxyURL) } - utlsRT := newUtlsRoundTripper(proxyURL) - - var standardTransport http.RoundTripper = &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, + var ctxRoundTripper http.RoundTripper + if ctx != nil { + ctxRoundTripper, _ = ctx.Value("cliproxy.roundtripper").(http.RoundTripper) } + + var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL) + var standardTransport http.RoundTripper = http.DefaultTransport if proxyURL != "" { if transport := buildProxyTransport(proxyURL); transport != nil { standardTransport = transport } + } else if ctxRoundTripper != nil { + utlsRT = ctxRoundTripper + standardTransport = ctxRoundTripper } client := &http.Client{ diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go new file mode 100644 index 000000000..093ad4bef --- /dev/null +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -0,0 +1,45 @@ +package helps + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type utlsClientRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f utlsClientRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestNewUtlsHTTPClientUsesContextRoundTripperForProtectedHost(t *testing.T) { + t.Parallel() + + called := false + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", utlsClientRoundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + if req.URL.Hostname() != "chatgpt.com" { + t.Fatalf("hostname = %q, want chatgpt.com", req.URL.Hostname()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("{}")), + Request: req, + }, nil + })) + + client := NewUtlsHTTPClient(ctx, nil, nil, 0) + resp, err := client.Get("https://chatgpt.com/backend-api/codex/responses") + if err != nil { + t.Fatalf("client.Get returned error: %v", err) + } + if errClose := resp.Body.Close(); errClose != nil { + t.Fatalf("response body close returned error: %v", errClose) + } + if !called { + t.Fatal("expected context RoundTripper to handle protected host request") + } +}