Skip to content

Commit 530bc36

Browse files
committed
refactor(management): reuse shared fingerprint RoundTripper for api-call
Route ChatGPT/Anthropic hosts through helps.NewFingerprintRoundTripper and IsChatGPTUpstreamURL so management APICall tracks the same host gate and ClientHello evolution as Codex/Claude, instead of a private chrome-only copy that can drift over time.
1 parent 4468660 commit 530bc36

5 files changed

Lines changed: 127 additions & 91 deletions

File tree

internal/api/handlers/management/api_tools.go

Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ const defaultAPICallTimeout = 60 * time.Second
2525
// for chatgpt.com so Go's default "Go-http-client" UA is not sent.
2626
const chromeAPICallUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
2727

28-
// newChromeAPICallTransport builds the Chrome-impersonating transport for
29-
// chatgpt.com. Tests replace this to avoid live TLS handshakes.
30-
var newChromeAPICallTransport = func(proxyURL string) http.RoundTripper {
31-
return helps.NewChromeRoundTripper(proxyURL)
28+
// newAPICallFingerprintTransport builds the shared Anthropic/ChatGPT
29+
// fingerprint RoundTripper. Host routing and ClientHello live in helps so they
30+
// evolve with Codex/Claude instead of a private management copy. Tests replace
31+
// this to avoid live TLS handshakes.
32+
var newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper {
33+
return helps.NewFingerprintRoundTripper(proxyURL, fallback)
3234
}
3335

3436
const (
@@ -92,10 +94,11 @@ type apiCallResponse struct {
9294
// 3. Global config proxy-url
9395
// 4. Direct connect (environment proxies are not used)
9496
//
95-
// ChatGPT (https://chatgpt.com) uses the same Chrome TLS/HTTP2 fingerprint as
96-
// Codex. Callers may send ChatGPT web headers (User-Agent, oai-language,
97-
// x-openai-target-path / x-openai-target-route); missing ones are filled only
98-
// for that host. Other hosts keep the standard transport.
97+
// ChatGPT (https://chatgpt.com) and Anthropic HTTPS origins reuse the shared
98+
// helps fingerprint RoundTripper (same routing as Codex/Claude). Callers may
99+
// send ChatGPT web headers (User-Agent, oai-language, x-openai-target-path /
100+
// x-openai-target-route); missing ones are filled only for ChatGPT. Other hosts
101+
// keep the standard transport.
99102
//
100103
// Response JSON (returned with HTTP 200 when the APICall itself succeeds):
101104
// - status_code: Upstream HTTP status code.
@@ -500,10 +503,9 @@ func (h *Handler) authByIndex(authIndex string) *coreauth.Auth {
500503
}
501504

502505
func (h *Handler) apiCallClientTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper {
503-
return &apiCallRoundTripper{
504-
chrome: newChromeAPICallTransport(h.apiCallProxyURL(auth, requestProxyURL)),
505-
fallback: h.apiCallTransport(auth, requestProxyURL),
506-
}
506+
proxyURL := h.apiCallProxyURL(auth, requestProxyURL)
507+
fallback := h.apiCallTransport(auth, requestProxyURL)
508+
return newAPICallFingerprintTransport(proxyURL, fallback)
507509
}
508510

509511
func (h *Handler) apiCallProxyURL(auth *coreauth.Auth, requestProxyURL string) string {
@@ -551,30 +553,8 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string)
551553
return directAPICallTransport()
552554
}
553555

554-
type apiCallRoundTripper struct {
555-
chrome http.RoundTripper
556-
fallback http.RoundTripper
557-
}
558-
559-
func (t *apiCallRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
560-
if t == nil {
561-
return nil, fmt.Errorf("api-call transport is nil")
562-
}
563-
if usesChromeAPICallTLS(req.URL) && t.chrome != nil {
564-
return t.chrome.RoundTrip(req)
565-
}
566-
if t.fallback == nil {
567-
return nil, fmt.Errorf("api-call fallback transport is nil")
568-
}
569-
return t.fallback.RoundTrip(req)
570-
}
571-
572-
func usesChromeAPICallTLS(u *url.URL) bool {
573-
return u != nil && u.Scheme == "https" && strings.EqualFold(u.Hostname(), "chatgpt.com")
574-
}
575-
576556
func applyChatGPTAPICallHeaderDefaults(req *http.Request) {
577-
if req == nil || !usesChromeAPICallTLS(req.URL) {
557+
if req == nil || !helps.IsChatGPTUpstreamURL(req.URL) {
578558
return
579559
}
580560
setHeaderDefault := func(key, value string) {

internal/api/handlers/management/api_tools_test.go

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9-
"net/url"
109
"strings"
1110
"testing"
1211

1312
"github.com/gin-gonic/gin"
1413
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
14+
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
1515
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
1616
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
1717
)
@@ -22,17 +22,25 @@ func (f apiCallRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, erro
2222
return f(req)
2323
}
2424

25-
func stubChromeAPICallTransport(t *testing.T, proxy *string, trip apiCallRoundTripFunc) {
25+
func stubAPICallFingerprintTransport(t *testing.T, proxy *string, trip apiCallRoundTripFunc) {
2626
t.Helper()
27-
original := newChromeAPICallTransport
27+
original := newAPICallFingerprintTransport
2828
t.Cleanup(func() {
29-
newChromeAPICallTransport = original
29+
newAPICallFingerprintTransport = original
3030
})
31-
newChromeAPICallTransport = func(proxyURL string) http.RoundTripper {
31+
newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper {
3232
if proxy != nil {
3333
*proxy = proxyURL
3434
}
35-
return trip
35+
return apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
36+
if helps.IsChatGPTUpstreamURL(req.URL) {
37+
return trip(req)
38+
}
39+
if fallback == nil {
40+
return nil, io.EOF
41+
}
42+
return fallback.RoundTrip(req)
43+
})
3644
}
3745
}
3846

@@ -338,35 +346,6 @@ func TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) {
338346
}
339347
}
340348

341-
func TestUsesChromeAPICallTLS(t *testing.T) {
342-
t.Parallel()
343-
344-
cases := []struct {
345-
name string
346-
raw string
347-
want bool
348-
}{
349-
{name: "chatgpt backend-api", raw: "https://chatgpt.com/backend-api/subscriptions", want: true},
350-
{name: "chatgpt mixed case host", raw: "https://ChatGPT.com/backend-api/codex/responses", want: true},
351-
{name: "chatgpt http", raw: "http://chatgpt.com/backend-api/subscriptions", want: false},
352-
{name: "lookalike host", raw: "https://chatgpt.com.example/backend-api/subscriptions", want: false},
353-
{name: "other https", raw: "https://api.example.com/v1/ping", want: false},
354-
}
355-
for _, tc := range cases {
356-
tc := tc
357-
t.Run(tc.name, func(t *testing.T) {
358-
t.Parallel()
359-
parsed, errParse := url.Parse(tc.raw)
360-
if errParse != nil {
361-
t.Fatalf("parse url: %v", errParse)
362-
}
363-
if got := usesChromeAPICallTLS(parsed); got != tc.want {
364-
t.Fatalf("usesChromeAPICallTLS(%q) = %v, want %v", tc.raw, got, tc.want)
365-
}
366-
})
367-
}
368-
}
369-
370349
func TestApplyChatGPTAPICallHeaderDefaults(t *testing.T) {
371350
t.Parallel()
372351

@@ -445,7 +424,7 @@ func TestApplyChatGPTAPICallHeaderDefaults(t *testing.T) {
445424
func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) {
446425
var gotProxy string
447426
var gotReq *http.Request
448-
stubChromeAPICallTransport(t, &gotProxy, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
427+
stubAPICallFingerprintTransport(t, &gotProxy, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
449428
gotReq = req.Clone(req.Context())
450429
return &http.Response{
451430
StatusCode: http.StatusOK,
@@ -483,7 +462,7 @@ func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) {
483462
t.Fatalf("upstream body = %q, want subscription JSON", response.Body)
484463
}
485464
if gotProxy != "http://request-proxy.example.com:8080" {
486-
t.Fatalf("chrome proxy = %q, want request proxy", gotProxy)
465+
t.Fatalf("fingerprint proxy = %q, want request proxy", gotProxy)
487466
}
488467
if gotReq == nil {
489468
t.Fatal("expected chrome transport to receive the upstream request")
@@ -501,7 +480,7 @@ func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) {
501480

502481
func TestAPICallNonChatGPTDoesNotUseChromeTransport(t *testing.T) {
503482
chromeCalled := false
504-
stubChromeAPICallTransport(t, nil, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
483+
stubAPICallFingerprintTransport(t, nil, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
505484
chromeCalled = true
506485
return &http.Response{
507486
StatusCode: http.StatusTeapot,
@@ -551,14 +530,15 @@ func TestAPICallNonChatGPTDoesNotUseChromeTransport(t *testing.T) {
551530
}
552531
}
553532

554-
func TestAPICallClientTransportPassesResolvedProxyToChrome(t *testing.T) {
533+
func TestAPICallClientTransportPassesResolvedProxyToFingerprint(t *testing.T) {
555534
var gotProxy string
556-
original := newChromeAPICallTransport
535+
original := newAPICallFingerprintTransport
557536
t.Cleanup(func() {
558-
newChromeAPICallTransport = original
537+
newAPICallFingerprintTransport = original
559538
})
560-
newChromeAPICallTransport = func(proxyURL string) http.RoundTripper {
539+
newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper {
561540
gotProxy = proxyURL
541+
_ = fallback
562542
return apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) {
563543
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: req}, nil
564544
})
@@ -572,24 +552,24 @@ func TestAPICallClientTransportPassesResolvedProxyToChrome(t *testing.T) {
572552
auth := &coreauth.Auth{ProxyURL: "http://credential-proxy.example.com:8080"}
573553
_ = h.apiCallClientTransport(auth, " http://request-proxy.example.com:8080 ")
574554
if gotProxy != "http://request-proxy.example.com:8080" {
575-
t.Fatalf("chrome proxy = %q, want request proxy", gotProxy)
555+
t.Fatalf("fingerprint proxy = %q, want request proxy", gotProxy)
576556
}
577557

578558
gotProxy = ""
579559
_ = h.apiCallClientTransport(auth, "")
580560
if gotProxy != "http://credential-proxy.example.com:8080" {
581-
t.Fatalf("chrome proxy = %q, want credential proxy", gotProxy)
561+
t.Fatalf("fingerprint proxy = %q, want credential proxy", gotProxy)
582562
}
583563

584564
gotProxy = ""
585565
_ = h.apiCallClientTransport(&coreauth.Auth{ProxyURL: "bad-value"}, "")
586566
if gotProxy != "http://global-proxy.example.com:8080" {
587-
t.Fatalf("chrome proxy = %q, want global proxy", gotProxy)
567+
t.Fatalf("fingerprint proxy = %q, want global proxy", gotProxy)
588568
}
589569

590570
gotProxy = ""
591571
_ = h.apiCallClientTransport(nil, "")
592572
if gotProxy != "http://global-proxy.example.com:8080" {
593-
t.Fatalf("chrome proxy = %q, want global proxy when auth is nil", gotProxy)
573+
t.Fatalf("fingerprint proxy = %q, want global proxy when auth is nil", gotProxy)
594574
}
595575
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package helps
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
)
7+
8+
// IsChatGPTUpstreamURL reports whether a resolved request targets ChatGPT's
9+
// first-party web origin. Chrome TLS/HTTP2 fingerprinting and ChatGPT web
10+
// header defaults must use this gate so management APICall and Codex cannot
11+
// drift onto lookalike hosts, custom ports, or userinfo URLs as the codebase
12+
// evolves.
13+
func IsChatGPTUpstreamURL(u *url.URL) bool {
14+
if u == nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "chatgpt.com") {
15+
return false
16+
}
17+
port := u.Port()
18+
return port == "" || port == "443"
19+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package helps
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
)
7+
8+
func TestIsChatGPTUpstreamURL(t *testing.T) {
9+
t.Parallel()
10+
11+
cases := []struct {
12+
name string
13+
raw string
14+
want bool
15+
}{
16+
{name: "backend-api", raw: "https://chatgpt.com/backend-api/subscriptions", want: true},
17+
{name: "mixed case host", raw: "https://ChatGPT.com/backend-api/codex/responses", want: true},
18+
{name: "explicit 443", raw: "https://chatgpt.com:443/backend-api/subscriptions", want: true},
19+
{name: "http", raw: "http://chatgpt.com/backend-api/subscriptions", want: false},
20+
{name: "custom port", raw: "https://chatgpt.com:8443/backend-api/subscriptions", want: false},
21+
{name: "lookalike host", raw: "https://chatgpt.com.example/backend-api/subscriptions", want: false},
22+
{name: "userinfo", raw: "https://user:pass@chatgpt.com/backend-api/subscriptions", want: false},
23+
{name: "other https", raw: "https://api.example.com/v1/ping", want: false},
24+
}
25+
for _, tc := range cases {
26+
tc := tc
27+
t.Run(tc.name, func(t *testing.T) {
28+
t.Parallel()
29+
parsed, errParse := url.Parse(tc.raw)
30+
if errParse != nil {
31+
t.Fatalf("parse url: %v", errParse)
32+
}
33+
if got := IsChatGPTUpstreamURL(parsed); got != tc.want {
34+
t.Fatalf("IsChatGPTUpstreamURL(%q) = %v, want %v", tc.raw, got, tc.want)
35+
}
36+
})
37+
}
38+
if IsChatGPTUpstreamURL(nil) {
39+
t.Fatal("IsChatGPTUpstreamURL(nil) = true")
40+
}
41+
}

internal/runtime/executor/helps/utls_client.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,27 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
363363
if IsAnthropicUpstreamURL(req.URL) {
364364
return f.anthropic.RoundTrip(req)
365365
}
366-
if req.URL.Scheme == "https" && strings.EqualFold(req.URL.Hostname(), "chatgpt.com") {
366+
if IsChatGPTUpstreamURL(req.URL) {
367367
return f.chrome.RoundTrip(req)
368368
}
369369
return f.fallback.RoundTrip(req)
370370
}
371371

372+
// NewFingerprintRoundTripper routes Anthropic and ChatGPT HTTPS origins through
373+
// the same provider TLS fingerprints Codex/Claude already use, and sends every
374+
// other request through fallback. Management APICall must use this helper (not a
375+
// private copy) so host routing and ClientHello selection keep evolving in one place.
376+
func NewFingerprintRoundTripper(proxyURL string, fallback http.RoundTripper) http.RoundTripper {
377+
if fallback == nil {
378+
fallback = http.DefaultTransport
379+
}
380+
return &fallbackRoundTripper{
381+
anthropic: cachedClaudeCodeRoundTripper(proxyURL),
382+
chrome: newUtlsRoundTripper(proxyURL),
383+
fallback: fallback,
384+
}
385+
}
386+
372387
// NewUtlsHTTPClient creates an HTTP client using provider-specific TLS
373388
// fingerprints for protected hosts. It uses Claude Code's Node/OpenSSL profile
374389
// for Anthropic and a Chrome profile for ChatGPT, with a standard-transport
@@ -387,25 +402,26 @@ func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyau
387402
ctxRoundTripper, _ = ctx.Value("cliproxy.roundtripper").(http.RoundTripper)
388403
}
389404

390-
var chromeRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
391-
var anthropicRT http.RoundTripper = cachedClaudeCodeRoundTripper(proxyURL)
392405
var standardTransport http.RoundTripper = http.DefaultTransport
393406
if proxyURL != "" {
394407
if transport := buildProxyTransport(proxyURL); transport != nil {
395408
standardTransport = transport
396409
}
397410
} else if ctxRoundTripper != nil {
398-
chromeRT = ctxRoundTripper
399-
anthropicRT = ctxRoundTripper
400411
standardTransport = ctxRoundTripper
401412
}
402413

414+
var transport http.RoundTripper
415+
if ctxRoundTripper != nil && proxyURL == "" {
416+
// Preserve the historical override: when a context round tripper is
417+
// injected and no auth/config proxy is set, all hosts share it.
418+
transport = ctxRoundTripper
419+
} else {
420+
transport = NewFingerprintRoundTripper(proxyURL, standardTransport)
421+
}
422+
403423
client := &http.Client{
404-
Transport: &fallbackRoundTripper{
405-
anthropic: anthropicRT,
406-
chrome: chromeRT,
407-
fallback: standardTransport,
408-
},
424+
Transport: transport,
409425
}
410426
if timeout > 0 {
411427
client.Timeout = timeout

0 commit comments

Comments
 (0)