diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 12bb53ac3..6c770abf4 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -59,10 +59,30 @@ type ClaudeAuth struct { // Returns: // - *ClaudeAuth: A new Claude authentication service instance func NewClaudeAuth(cfg *config.Config) *ClaudeAuth { + return NewClaudeAuthWithProxyURL(cfg, "") +} + +// NewClaudeAuthWithProxyURL creates a new Anthropic authentication service with a proxy override. +// proxyURL takes precedence over cfg.ProxyURL when non-empty. +func NewClaudeAuthWithProxyURL(cfg *config.Config, proxyURL string) *ClaudeAuth { + effectiveProxyURL := strings.TrimSpace(proxyURL) + var sdkCfg *config.SDKConfig + if cfg != nil { + sdkCfgCopy := cfg.SDKConfig + if effectiveProxyURL == "" { + effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) + } + sdkCfgCopy.ProxyURL = effectiveProxyURL + sdkCfg = &sdkCfgCopy + } else if effectiveProxyURL != "" { + sdkCfgCopy := config.SDKConfig{ProxyURL: effectiveProxyURL} + sdkCfg = &sdkCfgCopy + } + // Use custom HTTP client with Firefox TLS fingerprint to bypass // Cloudflare's bot detection on Anthropic domains return &ClaudeAuth{ - httpClient: NewAnthropicHttpClient(&cfg.SDKConfig), + httpClient: NewAnthropicHttpClient(sdkCfg), } } diff --git a/internal/auth/claude/anthropic_auth_proxy_test.go b/internal/auth/claude/anthropic_auth_proxy_test.go new file mode 100644 index 000000000..50c487579 --- /dev/null +++ b/internal/auth/claude/anthropic_auth_proxy_test.go @@ -0,0 +1,33 @@ +package claude + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "golang.org/x/net/proxy" +) + +func TestNewClaudeAuthWithProxyURL_OverrideDirectTakesPrecedence(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "socks5://proxy.example.com:1080"}} + auth := NewClaudeAuthWithProxyURL(cfg, "direct") + + transport, ok := auth.httpClient.Transport.(*utlsRoundTripper) + if !ok || transport == nil { + t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport) + } + if transport.dialer != proxy.Direct { + t.Fatalf("expected proxy.Direct, got %T", transport.dialer) + } +} + +func TestNewClaudeAuthWithProxyURL_OverrideProxyAppliedWithoutConfig(t *testing.T) { + auth := NewClaudeAuthWithProxyURL(nil, "socks5://proxy.example.com:1080") + + transport, ok := auth.httpClient.Transport.(*utlsRoundTripper) + if !ok || transport == nil { + t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport) + } + if transport.dialer == proxy.Direct { + t.Fatalf("expected proxy dialer, got %T", transport.dialer) + } +} diff --git a/internal/auth/codex/openai_auth.go b/internal/auth/codex/openai_auth.go index 64bc00a67..67b54b172 100644 --- a/internal/auth/codex/openai_auth.go +++ b/internal/auth/codex/openai_auth.go @@ -37,8 +37,23 @@ type CodexAuth struct { // NewCodexAuth creates a new CodexAuth service instance. // It initializes an HTTP client with proxy settings from the provided configuration. func NewCodexAuth(cfg *config.Config) *CodexAuth { + return NewCodexAuthWithProxyURL(cfg, "") +} + +// NewCodexAuthWithProxyURL creates a new CodexAuth service instance. +// proxyURL takes precedence over cfg.ProxyURL when non-empty. +func NewCodexAuthWithProxyURL(cfg *config.Config, proxyURL string) *CodexAuth { + effectiveProxyURL := strings.TrimSpace(proxyURL) + var sdkCfg config.SDKConfig + if cfg != nil { + sdkCfg = cfg.SDKConfig + if effectiveProxyURL == "" { + effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) + } + } + sdkCfg.ProxyURL = effectiveProxyURL return &CodexAuth{ - httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), + httpClient: util.SetProxy(&sdkCfg, &http.Client{}), } } diff --git a/internal/auth/codex/openai_auth_test.go b/internal/auth/codex/openai_auth_test.go index 3327eb4ab..a7fe83072 100644 --- a/internal/auth/codex/openai_auth_test.go +++ b/internal/auth/codex/openai_auth_test.go @@ -7,6 +7,8 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -42,3 +44,37 @@ func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) { t.Fatalf("expected 1 refresh attempt, got %d", got) } } + +func TestNewCodexAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}} + auth := NewCodexAuthWithProxyURL(cfg, "direct") + + transport, ok := auth.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) + } + if transport.Proxy != nil { + t.Fatal("expected direct transport to disable proxy function") + } +} + +func TestNewCodexAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}} + auth := NewCodexAuthWithProxyURL(cfg, "http://override.example.com:8081") + + transport, ok := auth.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) + } + req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errReq != nil { + t.Fatalf("new request: %v", errReq) + } + proxyURL, errProxy := transport.Proxy(req) + if errProxy != nil { + t.Fatalf("proxy func: %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" { + t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL) + } +} diff --git a/internal/auth/iflow/iflow_auth.go b/internal/auth/iflow/iflow_auth.go index fa9f38c3e..cffa0460c 100644 --- a/internal/auth/iflow/iflow_auth.go +++ b/internal/auth/iflow/iflow_auth.go @@ -48,8 +48,23 @@ type IFlowAuth struct { // NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport. func NewIFlowAuth(cfg *config.Config) *IFlowAuth { + return NewIFlowAuthWithProxyURL(cfg, "") +} + +// NewIFlowAuthWithProxyURL constructs a new IFlowAuth with a proxy override. +// proxyURL takes precedence over cfg.ProxyURL when non-empty. +func NewIFlowAuthWithProxyURL(cfg *config.Config, proxyURL string) *IFlowAuth { client := &http.Client{Timeout: 30 * time.Second} - return &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, client)} + effectiveProxyURL := strings.TrimSpace(proxyURL) + var sdkCfg config.SDKConfig + if cfg != nil { + sdkCfg = cfg.SDKConfig + if effectiveProxyURL == "" { + effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) + } + } + sdkCfg.ProxyURL = effectiveProxyURL + return &IFlowAuth{httpClient: util.SetProxy(&sdkCfg, client)} } // AuthorizationURL builds the authorization URL and matching redirect URI. diff --git a/internal/auth/iflow/iflow_auth_test.go b/internal/auth/iflow/iflow_auth_test.go new file mode 100644 index 000000000..7512c7a7d --- /dev/null +++ b/internal/auth/iflow/iflow_auth_test.go @@ -0,0 +1,42 @@ +package iflow + +import ( + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestNewIFlowAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}} + auth := NewIFlowAuthWithProxyURL(cfg, "direct") + + transport, ok := auth.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) + } + if transport.Proxy != nil { + t.Fatal("expected direct transport to disable proxy function") + } +} + +func TestNewIFlowAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}} + auth := NewIFlowAuthWithProxyURL(cfg, "http://override.example.com:8081") + + transport, ok := auth.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport) + } + req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errReq != nil { + t.Fatalf("new request: %v", errReq) + } + proxyURL, errProxy := transport.Proxy(req) + if errProxy != nil { + t.Fatalf("proxy func: %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" { + t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL) + } +} diff --git a/internal/auth/kimi/kimi.go b/internal/auth/kimi/kimi.go index 8427a057e..ccb1a6c2f 100644 --- a/internal/auth/kimi/kimi.go +++ b/internal/auth/kimi/kimi.go @@ -102,10 +102,24 @@ func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient { // NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID. func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient { + return NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, deviceID, "") +} + +// NewDeviceFlowClientWithDeviceIDAndProxyURL creates a new device flow client with a proxy override. +// proxyURL takes precedence over cfg.ProxyURL when non-empty. +func NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg *config.Config, deviceID string, proxyURL string) *DeviceFlowClient { client := &http.Client{Timeout: 30 * time.Second} + effectiveProxyURL := strings.TrimSpace(proxyURL) + var sdkCfg config.SDKConfig if cfg != nil { - client = util.SetProxy(&cfg.SDKConfig, client) + sdkCfg = cfg.SDKConfig + if effectiveProxyURL == "" { + effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) + } } + sdkCfg.ProxyURL = effectiveProxyURL + client = util.SetProxy(&sdkCfg, client) + resolvedDeviceID := strings.TrimSpace(deviceID) if resolvedDeviceID == "" { resolvedDeviceID = getOrCreateDeviceID() diff --git a/internal/auth/kimi/kimi_proxy_test.go b/internal/auth/kimi/kimi_proxy_test.go new file mode 100644 index 000000000..130f34f52 --- /dev/null +++ b/internal/auth/kimi/kimi_proxy_test.go @@ -0,0 +1,42 @@ +package kimi + +import ( + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideDirectDisablesProxy(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}} + client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "direct") + + transport, ok := client.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport) + } + if transport.Proxy != nil { + t.Fatal("expected direct transport to disable proxy function") + } +} + +func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideProxyTakesPrecedence(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}} + client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "http://override.example.com:8081") + + transport, ok := client.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport) + } + req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errReq != nil { + t.Fatalf("new request: %v", errReq) + } + proxyURL, errProxy := transport.Proxy(req) + if errProxy != nil { + t.Fatalf("proxy func: %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" { + t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL) + } +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 0da329350..0311827ba 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -659,7 +659,7 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) ( if refreshToken == "" { return auth, nil } - svc := claudeauth.NewClaudeAuth(e.cfg) + svc := claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL) td, err := svc.RefreshTokens(ctx, refreshToken) if err != nil { return nil, err diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index acca590ae..41b1c3252 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -612,7 +612,7 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (* if refreshToken == "" { return auth, nil } - svc := codexauth.NewCodexAuth(e.cfg) + svc := codexauth.NewCodexAuthWithProxyURL(e.cfg, auth.ProxyURL) td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3) if err != nil { return nil, err diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 8c37b215a..da56ec3c2 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -381,7 +381,7 @@ func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyau log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email) - svc := iflowauth.NewIFlowAuth(e.cfg) + svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL) keyData, err := svc.RefreshAPIKey(ctx, cookie, email) if err != nil { log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err) @@ -429,7 +429,7 @@ func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyaut log.Debugf("iflow executor: refreshing access token, old: %s", util.HideAPIKey(oldAccessToken)) } - svc := iflowauth.NewIFlowAuth(e.cfg) + svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL) tokenData, err := svc.RefreshTokens(ctx, refreshToken) if err != nil { log.Errorf("iflow executor: token refresh failed: %v", err) diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 0c911085b..931e3a569 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -472,7 +472,7 @@ func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*c return auth, nil } - client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth)) + client := kimiauth.NewDeviceFlowClientWithDeviceIDAndProxyURL(e.cfg, resolveKimiDeviceID(auth), auth.ProxyURL) td, err := client.RefreshToken(ctx, refreshToken) if err != nil { return nil, err