diff --git a/internal/handlers/ai/ai_handlers.go b/internal/handlers/ai/ai_handlers.go index 3d265a07c..d211eb7f2 100644 --- a/internal/handlers/ai/ai_handlers.go +++ b/internal/handlers/ai/ai_handlers.go @@ -1,16 +1,15 @@ package handlers import ( - "fmt" "net/http" "net/url" - "strings" "time" "MrRSS/internal/ai" "MrRSS/internal/config" "MrRSS/internal/handlers/core" "MrRSS/internal/handlers/response" + "MrRSS/internal/utils/httputil" ) // TestResult represents the result of AI configuration test @@ -171,59 +170,5 @@ func HandleGetAITestInfo(h *core.Handler, w http.ResponseWriter, r *http.Request // createHTTPClientWithProxy creates an HTTP client with global proxy settings if enabled func createHTTPClientWithProxy(h *core.Handler) (*http.Client, error) { - // Check if global proxy is enabled - proxyEnabled, _ := h.DB.GetSetting("proxy_enabled") - if proxyEnabled != "true" { - return &http.Client{}, nil - } - - // Build proxy URL from global settings - proxyType, _ := h.DB.GetSetting("proxy_type") - proxyHost, _ := h.DB.GetSetting("proxy_host") - proxyPort, _ := h.DB.GetSetting("proxy_port") - proxyUsername, _ := h.DB.GetEncryptedSetting("proxy_username") - proxyPassword, _ := h.DB.GetEncryptedSetting("proxy_password") - - // Build proxy URL - proxyURL := buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - - if proxyURL == "" { - return &http.Client{}, nil - } - - // Parse proxy URL - u, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy URL: %w", err) - } - - return &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(u), - }, - }, nil -} - -// buildProxyURL builds a proxy URL from components -func buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword string) string { - if proxyHost == "" || proxyPort == "" { - return "" - } - - var urlBuilder strings.Builder - urlBuilder.WriteString(strings.ToLower(proxyType)) - urlBuilder.WriteString("://") - - if proxyUsername != "" && proxyPassword != "" { - urlBuilder.WriteString(url.QueryEscape(proxyUsername)) - urlBuilder.WriteString(":") - urlBuilder.WriteString(url.QueryEscape(proxyPassword)) - urlBuilder.WriteString("@") - } - - urlBuilder.WriteString(proxyHost) - urlBuilder.WriteString(":") - urlBuilder.WriteString(proxyPort) - - return urlBuilder.String() + return httputil.CreateHTTPClientWithProxySettings(h.DB, 30*time.Second) } diff --git a/internal/handlers/ai/ai_profiles_handlers.go b/internal/handlers/ai/ai_profiles_handlers.go index a4384bb23..88a07aff5 100644 --- a/internal/handlers/ai/ai_profiles_handlers.go +++ b/internal/handlers/ai/ai_profiles_handlers.go @@ -15,6 +15,7 @@ import ( "MrRSS/internal/handlers/core" "MrRSS/internal/handlers/response" "MrRSS/internal/models" + "MrRSS/internal/utils/httputil" ) // ProfileRequest represents the request body for creating/updating an AI profile @@ -546,50 +547,5 @@ func testAIProfileConnection(h *core.Handler, profile *models.AIProfile) Profile // createHTTPClientWithProxyForProfile creates an HTTP client with global proxy settings func createHTTPClientWithProxyForProfile(h *core.Handler) (*http.Client, error) { - proxyEnabled, _ := h.DB.GetSetting("proxy_enabled") - if proxyEnabled != "true" { - return &http.Client{}, nil - } - - proxyType, _ := h.DB.GetSetting("proxy_type") - proxyHost, _ := h.DB.GetSetting("proxy_host") - proxyPort, _ := h.DB.GetSetting("proxy_port") - proxyUsername, _ := h.DB.GetEncryptedSetting("proxy_username") - proxyPassword, _ := h.DB.GetEncryptedSetting("proxy_password") - - proxyURL := buildProxyURLForProfile(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - if proxyURL == "" { - return &http.Client{}, nil - } - - u, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy URL: %w", err) - } - - return &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(u), - }, - }, nil -} - -// buildProxyURLForProfile builds a proxy URL from components -func buildProxyURLForProfile(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword string) string { - if proxyHost == "" || proxyPort == "" { - return "" - } - - scheme := "http" - switch proxyType { - case "socks5": - scheme = "socks5" - case "https": - scheme = "http" // HTTPS proxies use HTTP CONNECT - } - - if proxyUsername != "" && proxyPassword != "" { - return fmt.Sprintf("%s://%s:%s@%s:%s", scheme, proxyUsername, proxyPassword, proxyHost, proxyPort) - } - return fmt.Sprintf("%s://%s:%s", scheme, proxyHost, proxyPort) + return httputil.CreateHTTPClientWithProxySettings(h.DB, 30*time.Second) } diff --git a/internal/handlers/chat/chat_handlers.go b/internal/handlers/chat/chat_handlers.go index 7887f8357..b0b698bb9 100644 --- a/internal/handlers/chat/chat_handlers.go +++ b/internal/handlers/chat/chat_handlers.go @@ -5,13 +5,13 @@ import ( "fmt" "log" "net/http" - "net/url" "strings" "time" "MrRSS/internal/ai" "MrRSS/internal/handlers/core" "MrRSS/internal/handlers/response" + "MrRSS/internal/utils/httputil" "MrRSS/internal/utils/textutil" ) @@ -285,65 +285,7 @@ func estimateChatTokens(messages []ChatMessage, response string) int { return totalChars / 4 } -// createHTTPClientWithProxy creates an HTTP client with global proxy settings if enabled +// createHTTPClientWithProxy creates the canonical HTTP client with global proxy settings. func createHTTPClientWithProxy(h *core.Handler) (*http.Client, error) { - // Check if global proxy is enabled - proxyEnabled, _ := h.DB.GetSetting("proxy_enabled") - if proxyEnabled != "true" { - return &http.Client{Timeout: 60 * time.Second}, nil - } - - // Build proxy URL from global settings - proxyType, _ := h.DB.GetSetting("proxy_type") - proxyHost, _ := h.DB.GetSetting("proxy_host") - proxyPort, _ := h.DB.GetSetting("proxy_port") - proxyUsername, _ := h.DB.GetEncryptedSetting("proxy_username") - proxyPassword, _ := h.DB.GetEncryptedSetting("proxy_password") - - // Build proxy URL - proxyURL := buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - - // Create HTTP client with proxy - return createHTTPClient(proxyURL, 60*time.Second) -} - -// buildProxyURL builds a proxy URL from components -func buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword string) string { - if proxyHost == "" || proxyPort == "" { - return "" - } - - var urlBuilder strings.Builder - urlBuilder.WriteString(strings.ToLower(proxyType)) - urlBuilder.WriteString("://") - - if proxyUsername != "" && proxyPassword != "" { - urlBuilder.WriteString(url.QueryEscape(proxyUsername)) - urlBuilder.WriteString(":") - urlBuilder.WriteString(url.QueryEscape(proxyPassword)) - urlBuilder.WriteString("@") - } - - urlBuilder.WriteString(proxyHost) - urlBuilder.WriteString(":") - urlBuilder.WriteString(proxyPort) - - return urlBuilder.String() -} - -// createHTTPClient creates an HTTP client with optional proxy -func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { - client := &http.Client{Timeout: timeout} - - if proxyURL != "" { - u, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy URL: %w", err) - } - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(u), - } - } - - return client, nil + return httputil.CreateHTTPClientWithProxySettings(h.DB, 60*time.Second) } diff --git a/internal/service/ai_service.go b/internal/service/ai_service.go index 82344d573..3d4f68c90 100644 --- a/internal/service/ai_service.go +++ b/internal/service/ai_service.go @@ -5,13 +5,13 @@ import ( "fmt" "net/http" "net/url" - "strings" "time" "MrRSS/internal/ai" "MrRSS/internal/config" "MrRSS/internal/database" "MrRSS/internal/models" + "MrRSS/internal/utils/httputil" ) // aiService implements AIService interface @@ -49,14 +49,18 @@ func (s *aiService) Summarize(ctx context.Context, content string) (string, erro model = defaults.AIModel } - // Create AI client + // Create AI client using the same transport as configuration tests. + httpClient, err := s.createHTTPClientWithProxy() + if err != nil { + return "", fmt.Errorf("failed to create HTTP client: %w", err) + } clientConfig := ai.ClientConfig{ APIKey: apiKey, Endpoint: endpoint, Model: model, Timeout: 30 * time.Second, } - client := ai.NewClient(clientConfig) + client := ai.NewClientWithHTTPClient(clientConfig, httpClient) // Generate summary response, err := client.Request(content, "Summarize this article") @@ -91,14 +95,18 @@ func (s *aiService) Chat(ctx context.Context, sessionID int64, message string) ( model = defaults.AIModel } - // Create AI client + // Create AI client using the same transport as configuration tests. + httpClient, err := s.createHTTPClientWithProxy() + if err != nil { + return "", fmt.Errorf("failed to create HTTP client: %w", err) + } clientConfig := ai.ClientConfig{ APIKey: apiKey, Endpoint: endpoint, Model: model, Timeout: 30 * time.Second, } - client := ai.NewClient(clientConfig) + client := ai.NewClientWithHTTPClient(clientConfig, httpClient) // Send chat message response, err := client.Request(message, "") @@ -166,61 +174,7 @@ func (s *aiService) TestConfig(ctx context.Context) error { return err } -// createHTTPClientWithProxy creates an HTTP client with global proxy settings if enabled +// createHTTPClientWithProxy creates the canonical HTTP client with global proxy settings. func (s *aiService) createHTTPClientWithProxy() (*http.Client, error) { - // Check if global proxy is enabled - proxyEnabled, _ := s.db.GetSetting("proxy_enabled") - if proxyEnabled != "true" { - return &http.Client{}, nil - } - - // Build proxy URL from global settings - proxyType, _ := s.db.GetSetting("proxy_type") - proxyHost, _ := s.db.GetSetting("proxy_host") - proxyPort, _ := s.db.GetSetting("proxy_port") - proxyUsername, _ := s.db.GetEncryptedSetting("proxy_username") - proxyPassword, _ := s.db.GetEncryptedSetting("proxy_password") - - // Build proxy URL - proxyURL := s.buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - - if proxyURL == "" { - return &http.Client{}, nil - } - - // Parse proxy URL - u, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy URL: %w", err) - } - - return &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(u), - }, - }, nil -} - -// buildProxyURL builds a proxy URL from components -func (s *aiService) buildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword string) string { - if proxyHost == "" || proxyPort == "" { - return "" - } - - var urlBuilder strings.Builder - urlBuilder.WriteString(strings.ToLower(proxyType)) - urlBuilder.WriteString("://") - - if proxyUsername != "" && proxyPassword != "" { - urlBuilder.WriteString(url.QueryEscape(proxyUsername)) - urlBuilder.WriteString(":") - urlBuilder.WriteString(url.QueryEscape(proxyPassword)) - urlBuilder.WriteString("@") - } - - urlBuilder.WriteString(proxyHost) - urlBuilder.WriteString(":") - urlBuilder.WriteString(proxyPort) - - return urlBuilder.String() + return httputil.CreateHTTPClientWithProxySettings(s.db, 30*time.Second) } diff --git a/internal/summary/ai_summarizer.go b/internal/summary/ai_summarizer.go index e21e79198..71855c256 100644 --- a/internal/summary/ai_summarizer.go +++ b/internal/summary/ai_summarizer.go @@ -20,6 +20,7 @@ type AISummarizer struct { CustomHeaders string Language string // User's language setting (e.g., "en", "zh") client *ai.Client + httpClient *http.Client } // DBInterface defines the minimal database interface needed for proxy settings @@ -30,22 +31,7 @@ type DBInterface interface { // CreateHTTPClientWithProxy creates an HTTP client with global proxy settings if enabled func CreateHTTPClientWithProxy(db DBInterface, timeout time.Duration) (*http.Client, error) { - var proxyURL string - - // Check if global proxy is enabled - proxyEnabled, _ := db.GetSetting("proxy_enabled") - if proxyEnabled == "true" { - // Build proxy URL from global settings - proxyType, _ := db.GetSetting("proxy_type") - proxyHost, _ := db.GetSetting("proxy_host") - proxyPort, _ := db.GetSetting("proxy_port") - proxyUsername, _ := db.GetEncryptedSetting("proxy_username") - proxyPassword, _ := db.GetEncryptedSetting("proxy_password") - proxyURL = httputil.BuildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - } - - // Create HTTP client with or without proxy - return httputil.CreateHTTPClient(proxyURL, timeout) + return httputil.CreateHTTPClientWithProxySettings(db, timeout) } // NewAISummarizer creates a new AI summarizer with the given credentials. @@ -62,22 +48,22 @@ func NewAISummarizer(apiKey, endpoint, model string) *AISummarizer { model = defaults.AIModel } - clientConfig := ai.ClientConfig{ - APIKey: apiKey, - Endpoint: strings.TrimSuffix(endpoint, "/"), - Model: model, - Timeout: 30 * time.Second, + httpClient, err := CreateHTTPClientWithProxy(nil, 30*time.Second) + if err != nil { + httpClient = &http.Client{Timeout: 30 * time.Second} } - return &AISummarizer{ + summarizer := &AISummarizer{ APIKey: apiKey, Endpoint: strings.TrimSuffix(endpoint, "/"), Model: model, SystemPrompt: "", // Will be set from settings when used CustomHeaders: "", // Will be set from settings when used Language: "en", // Default to English - client: ai.NewClient(clientConfig), + httpClient: httpClient, } + summarizer.recreateClient() + return summarizer } // NewAISummarizerWithDB creates a new AI summarizer with database for proxy support @@ -96,22 +82,17 @@ func NewAISummarizerWithDB(apiKey, endpoint, model string, db DBInterface) *AISu httpClient = &http.Client{Timeout: 30 * time.Second} } - clientConfig := ai.ClientConfig{ - APIKey: apiKey, - Endpoint: strings.TrimSuffix(endpoint, "/"), - Model: model, - Timeout: 30 * time.Second, - } - - return &AISummarizer{ + summarizer := &AISummarizer{ APIKey: apiKey, Endpoint: strings.TrimSuffix(endpoint, "/"), Model: model, SystemPrompt: "", CustomHeaders: "", // Will be set from settings when used Language: "en", // Default to English - client: ai.NewClientWithHTTPClient(clientConfig, httpClient), + httpClient: httpClient, } + summarizer.recreateClient() + return summarizer } // SetSystemPrompt sets a custom system prompt for the summarizer. @@ -146,7 +127,7 @@ func (s *AISummarizer) recreateClient() { CustomHeaders: s.CustomHeaders, Timeout: 30 * time.Second, } - s.client = ai.NewClient(clientConfig) + s.client = ai.NewClientWithHTTPClient(clientConfig, s.httpClient) } // getDefaultSystemPrompt returns the default system prompt based on the configured language. diff --git a/internal/translation/ai.go b/internal/translation/ai.go index 25a620d89..847fdceed 100644 --- a/internal/translation/ai.go +++ b/internal/translation/ai.go @@ -18,41 +18,22 @@ type AITranslator struct { SystemPrompt string CustomHeaders string client *ai.Client + httpClient *http.Client } // NewAITranslator creates a new AI translator with the given credentials. // endpoint should be the full API URL (e.g., "https://api.openai.com/v1/chat/completions" for OpenAI, "http://localhost:11434/api/generate" for Ollama) // model should be the model name (e.g., "gpt-4o-mini", "claude-3-haiku-20240307") func NewAITranslator(apiKey, endpoint, model string) *AITranslator { - defaults := config.Get() - // Default to OpenAI endpoint if not specified - if endpoint == "" { - endpoint = defaults.AIEndpoint - } - // Default to a cost-effective model if not specified - if model == "" { - model = defaults.AIModel - } - - clientConfig := ai.ClientConfig{ - APIKey: apiKey, - Endpoint: strings.TrimSuffix(endpoint, "/"), - Model: model, - Timeout: 30 * time.Second, - } - - return &AITranslator{ - APIKey: apiKey, - Endpoint: strings.TrimSuffix(endpoint, "/"), - Model: model, - SystemPrompt: "", // Will be set from settings when used - CustomHeaders: "", // Will be set from settings when used - client: ai.NewClient(clientConfig), - } + return newAITranslator(apiKey, endpoint, model, nil) } -// NewAITranslatorWithDB creates a new AI translator with database for proxy support +// NewAITranslatorWithDB creates a new AI translator with database for proxy support. func NewAITranslatorWithDB(apiKey, endpoint, model string, db DBInterface) *AITranslator { + return newAITranslator(apiKey, endpoint, model, db) +} + +func newAITranslator(apiKey, endpoint, model string, db DBInterface) *AITranslator { defaults := config.Get() if endpoint == "" { endpoint = defaults.AIEndpoint @@ -63,55 +44,44 @@ func NewAITranslatorWithDB(apiKey, endpoint, model string, db DBInterface) *AITr httpClient, err := CreateHTTPClientWithProxy(db, 30*time.Second) if err != nil { - // Fallback to default client if proxy creation fails + // Keep a usable HTTP/2-capable fallback when proxy settings are invalid. httpClient = &http.Client{Timeout: 30 * time.Second} } - clientConfig := ai.ClientConfig{ - APIKey: apiKey, - Endpoint: strings.TrimSuffix(endpoint, "/"), - Model: model, - Timeout: 30 * time.Second, - } - - return &AITranslator{ + translator := &AITranslator{ APIKey: apiKey, Endpoint: strings.TrimSuffix(endpoint, "/"), Model: model, SystemPrompt: "", - CustomHeaders: "", // Will be set from settings when used - client: ai.NewClientWithHTTPClient(clientConfig, httpClient), + CustomHeaders: "", + httpClient: httpClient, } + translator.recreateClient() + return translator } // SetSystemPrompt sets a custom system prompt for the translator. func (t *AITranslator) SetSystemPrompt(prompt string) { t.SystemPrompt = prompt - // Re-create client with updated system prompt - clientConfig := ai.ClientConfig{ - APIKey: t.APIKey, - Endpoint: t.Endpoint, - Model: t.Model, - SystemPrompt: prompt, - CustomHeaders: t.CustomHeaders, - Timeout: 30 * time.Second, - } - t.client = ai.NewClient(clientConfig) + t.recreateClient() } // SetCustomHeaders sets custom headers for AI requests. func (t *AITranslator) SetCustomHeaders(headers string) { t.CustomHeaders = headers - // Re-create client with updated custom headers + t.recreateClient() +} + +func (t *AITranslator) recreateClient() { clientConfig := ai.ClientConfig{ APIKey: t.APIKey, Endpoint: t.Endpoint, Model: t.Model, SystemPrompt: t.SystemPrompt, - CustomHeaders: headers, + CustomHeaders: t.CustomHeaders, Timeout: 30 * time.Second, } - t.client = ai.NewClient(clientConfig) + t.client = ai.NewClientWithHTTPClient(clientConfig, t.httpClient) } // Translate translates text to the target language using an OpenAI-compatible API. diff --git a/internal/translation/factory.go b/internal/translation/factory.go index 9137774c0..a185271c9 100644 --- a/internal/translation/factory.go +++ b/internal/translation/factory.go @@ -137,7 +137,7 @@ func (f *Factory) createBaiduProvider(config *baiduConfig) Provider { // createAIProvider 创建 AI 翻译提供商 func (f *Factory) createAIProvider(config *aiConfig) Provider { - translator := NewAITranslator(config.APIKey, config.Endpoint, config.Model) + translator := NewAITranslatorWithDB(config.APIKey, config.Endpoint, config.Model, f.settingsProvider) if config.SystemPrompt != "" { translator.SetSystemPrompt(config.SystemPrompt) } diff --git a/internal/translation/translation_providers_test.go b/internal/translation/translation_providers_test.go index ec3c0bf17..85ae107fa 100644 --- a/internal/translation/translation_providers_test.go +++ b/internal/translation/translation_providers_test.go @@ -4,11 +4,13 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" "time" "MrRSS/internal/ai" + "MrRSS/internal/utils/httputil" ) type rtFunc func(*http.Request) (*http.Response, error) @@ -158,3 +160,66 @@ func TestAITranslate_RemovesThinkingBlocks(t *testing.T) { t.Fatalf("expected thinking-free translation, got %q", out) } } + +func TestAITranslatorRetainsHTTPClientAfterConfigurationChanges(t *testing.T) { + translator := NewAITranslator("apikey", "https://api.test", "m1") + requestCount := 0 + injectedClient := &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + if req.Header.Get("X-Gateway") != "enabled" { + t.Fatalf("expected custom header to be retained, got %q", req.Header.Get("X-Gateway")) + } + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if !strings.Contains(string(bodyBytes), "Custom translation prompt") { + t.Fatalf("expected custom system prompt in request: %s", string(bodyBytes)) + } + body := `{"choices":[{"message":{"content":"Bonjour"}}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": {"application/json"}}, + }, nil + }), Timeout: 5 * time.Second} + + translator.httpClient = injectedClient + translator.SetSystemPrompt("Custom translation prompt") + translator.SetCustomHeaders(`{"X-Gateway":"enabled"}`) + + translated, err := translator.Translate("Hello", "fr") + if err != nil { + t.Fatalf("Translate failed: %v", err) + } + if translated != "Bonjour" { + t.Fatalf("expected Bonjour, got %q", translated) + } + if requestCount != 1 { + t.Fatalf("expected injected transport to receive one request, got %d", requestCount) + } +} + +func TestAITranslatorNegotiatesHTTP2WithCompatibleGateway(t *testing.T) { + t.Setenv(httputil.InsecureSkipTLSVerifyEnv, "true") + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor != 2 { + t.Errorf("expected translation request over HTTP/2, got %s", r.Proto) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":"Bonjour","done":true}`) + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + translator := NewAITranslator("", server.URL+"/api/generate", "test-model") + translated, err := translator.Translate("Hello", "fr") + if err != nil { + t.Fatalf("Translate failed over HTTP/2: %v", err) + } + if translated != "Bonjour" { + t.Fatalf("expected Bonjour, got %q", translated) + } +} diff --git a/internal/translation/translator.go b/internal/translation/translator.go index 9b6a113d0..06972df73 100644 --- a/internal/translation/translator.go +++ b/internal/translation/translator.go @@ -22,24 +22,7 @@ type DBInterface interface { // CreateHTTPClientWithProxy creates an HTTP client with global proxy settings if enabled func CreateHTTPClientWithProxy(db DBInterface, timeout time.Duration) (*http.Client, error) { - var proxyURL string - - // Check if global proxy is enabled - if db != nil { - proxyEnabled, _ := db.GetSetting("proxy_enabled") - if proxyEnabled == "true" { - // Build proxy URL from global settings - proxyType, _ := db.GetSetting("proxy_type") - proxyHost, _ := db.GetSetting("proxy_host") - proxyPort, _ := db.GetSetting("proxy_port") - proxyUsername, _ := db.GetEncryptedSetting("proxy_username") - proxyPassword, _ := db.GetEncryptedSetting("proxy_password") - proxyURL = httputil.BuildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) - } - } - - // Create HTTP client with or without proxy - return httputil.CreateHTTPClient(proxyURL, timeout) + return httputil.CreateHTTPClientWithProxySettings(db, timeout) } // MockTranslator is a simple translator for demonstration diff --git a/internal/translation/translator_test.go b/internal/translation/translator_test.go index 1989964ac..165db2e98 100644 --- a/internal/translation/translator_test.go +++ b/internal/translation/translator_test.go @@ -2,6 +2,7 @@ package translation import ( "net/http" + "net/url" "testing" "time" @@ -218,3 +219,47 @@ func TestCreateHTTPClientWithProxy_EnabledAndDisabled(t *testing.T) { t.Fatalf("expected proxy to be configured when enabled") } } + +func TestFactoryAIProviderUsesGlobalProxyClient(t *testing.T) { + settings := &mockSettingsProvider{settings: map[string]string{ + "proxy_enabled": "true", + "proxy_type": "http", + "proxy_host": "127.0.0.1", + "proxy_port": "3128", + "proxy_username": "user", + "proxy_password": "password", + }} + factory := NewFactory(settings) + provider := factory.createAIProvider(&aiConfig{ + APIKey: "key", + Endpoint: "https://api.example.com/v1/chat/completions", + Model: "model", + SystemPrompt: "prompt", + CustomHeaders: `{"X-Test":"value"}`, + }) + + transport, ok := provider.(*aiProvider).translator.httpClient.Transport.(*http.Transport) + if !ok { + t.Fatalf("unexpected transport type %T", provider.(*aiProvider).translator.httpClient.Transport) + } + request := &http.Request{URL: mustParseURL(t, "https://api.example.com")} + proxyURL, err := transport.Proxy(request) + if err != nil { + t.Fatalf("proxy lookup failed: %v", err) + } + if proxyURL == nil || proxyURL.String() != "http://user:password@127.0.0.1:3128" { + t.Fatalf("unexpected proxy URL %v", proxyURL) + } + if !transport.ForceAttemptHTTP2 { + t.Fatalf("expected actual translation transport to attempt HTTP/2") + } +} + +func mustParseURL(t *testing.T, rawURL string) *url.URL { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse URL: %v", err) + } + return parsed +} diff --git a/internal/utils/httputil/httputil.go b/internal/utils/httputil/httputil.go index 2c8263e42..c7d975cfd 100644 --- a/internal/utils/httputil/httputil.go +++ b/internal/utils/httputil/httputil.go @@ -39,18 +39,23 @@ func BuildProxyURL(proxyType, proxyHost, proxyPort, username, password string) s // CreateHTTPClient creates an HTTP client with optional proxy support. func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: insecureSkipTLSVerifyEnabled(), - }, - MaxIdleConns: 50, - MaxIdleConnsPerHost: 5, - IdleConnTimeout: 90 * time.Second, - ForceAttemptHTTP2: false, - WriteBufferSize: 32 * 1024, - ReadBufferSize: 32 * 1024, + transport := http.DefaultTransport.(*http.Transport).Clone() + // Do not inherit environment proxies implicitly. Proxy configuration is + // controlled by the application's global proxy settings below. + transport.Proxy = nil + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: insecureSkipTLSVerifyEnabled(), } + // A non-nil custom TLS config disables HTTP/2 auto-configuration unless + // ForceAttemptHTTP2 is enabled. Keep HTTP/2 negotiation while retaining + // transparent HTTP/1.1 fallback for compatible API gateways. + transport.ForceAttemptHTTP2 = true + transport.MaxIdleConns = 50 + transport.MaxIdleConnsPerHost = 5 + transport.IdleConnTimeout = 90 * time.Second + transport.WriteBufferSize = 32 * 1024 + transport.ReadBufferSize = 32 * 1024 if proxyURL != "" { parsedProxy, err := url.Parse(proxyURL) @@ -66,6 +71,32 @@ func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err }, nil } +// ProxySettingsProvider provides the global proxy settings needed to build an +// application HTTP client. +type ProxySettingsProvider interface { + GetSetting(key string) (string, error) + GetEncryptedSetting(key string) (string, error) +} + +// CreateHTTPClientWithProxySettings creates the canonical application HTTP +// client and applies the configured global proxy when enabled. +func CreateHTTPClientWithProxySettings(settings ProxySettingsProvider, timeout time.Duration) (*http.Client, error) { + var proxyURL string + if settings != nil { + proxyEnabled, _ := settings.GetSetting("proxy_enabled") + if proxyEnabled == "true" { + proxyType, _ := settings.GetSetting("proxy_type") + proxyHost, _ := settings.GetSetting("proxy_host") + proxyPort, _ := settings.GetSetting("proxy_port") + proxyUsername, _ := settings.GetEncryptedSetting("proxy_username") + proxyPassword, _ := settings.GetEncryptedSetting("proxy_password") + proxyURL = BuildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword) + } + } + + return CreateHTTPClient(proxyURL, timeout) +} + func insecureSkipTLSVerifyEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(InsecureSkipTLSVerifyEnv))) { case "1", "true", "yes", "y", "on": diff --git a/internal/utils/httputil/httputil_test.go b/internal/utils/httputil/httputil_test.go index eaeb5c84c..8d65ee2dd 100644 --- a/internal/utils/httputil/httputil_test.go +++ b/internal/utils/httputil/httputil_test.go @@ -2,7 +2,9 @@ package httputil import ( "crypto/tls" + "io" "net/http" + "net/http/httptest" "testing" "time" ) @@ -43,3 +45,47 @@ func TestCreateHTTPClientKeepsTLSVerificationByDefault(t *testing.T) { t.Fatalf("expected InsecureSkipVerify to be false by default") } } + +func TestCreateHTTPClientNegotiatesHTTP2(t *testing.T) { + t.Setenv(InsecureSkipTLSVerifyEnv, "true") + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor != 2 { + t.Errorf("expected HTTP/2 request, got %s", r.Proto) + } + _, _ = io.WriteString(w, "ok") + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + client, err := CreateHTTPClient("", 5*time.Second) + if err != nil { + t.Fatalf("CreateHTTPClient returned error: %v", err) + } + response, err := client.Get(server.URL) + if err != nil { + t.Fatalf("HTTP/2 request failed: %v", err) + } + defer response.Body.Close() +} + +func TestCreateHTTPClientFallsBackToHTTP1(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor != 1 { + t.Errorf("expected HTTP/1.1 request, got %s", r.Proto) + } + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + + client, err := CreateHTTPClient("", 5*time.Second) + if err != nil { + t.Fatalf("CreateHTTPClient returned error: %v", err) + } + response, err := client.Get(server.URL) + if err != nil { + t.Fatalf("HTTP/1.1 request failed: %v", err) + } + defer response.Body.Close() +}