From e99a9bd86fb256afbf94dd7648ac7928de13f8c6 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 27 Jul 2026 21:40:26 +0800 Subject: [PATCH] feat: add per-channel HTTP transport controls --- model/channel.go | 3 + model/channel_settings_test.go | 32 ++ relay/channel/api_request.go | 33 +- relay/channel/aws/relay-aws.go | 14 +- relay/channel/coze/relay-coze.go | 12 +- relay/channel/vertex/service_account.go | 12 +- relaykit/dto/channel_settings.go | 32 ++ relaykit/dto/channel_settings_test.go | 59 +++ service/http_client.go | 216 ++++++-- service/http_client_transport_test.go | 491 ++++++++++++++++++ service/http_transport_policy.go | 91 ++++ service/http_transport_sharded.go | 131 +++++ .../drawers/channel-mutate-drawer.tsx | 134 ++++- web/src/features/channels/constants.ts | 5 + .../channels/lib/channel-form-errors.ts | 2 + web/src/features/channels/lib/channel-form.ts | 79 ++- web/src/features/channels/types.ts | 2 + web/src/i18n/locales/en.json | 9 + web/src/i18n/locales/fr.json | 9 + web/src/i18n/locales/ja.json | 9 + web/src/i18n/locales/ru.json | 9 + web/src/i18n/locales/vi.json | 9 + web/src/i18n/locales/zh-TW.json | 9 + web/src/i18n/locales/zh.json | 9 + 24 files changed, 1330 insertions(+), 81 deletions(-) create mode 100644 service/http_client_transport_test.go create mode 100644 service/http_transport_policy.go create mode 100644 service/http_transport_sharded.go diff --git a/model/channel.go b/model/channel.go index f7cf5094..2cd7c311 100644 --- a/model/channel.go +++ b/model/channel.go @@ -954,6 +954,9 @@ func (channel *Channel) ValidateSettings() error { if _, err := common.ParseProxyURLStrict(channelParams.Proxy); err != nil { return fmt.Errorf("invalid channel proxy: %w", err) } + if err := channelParams.ValidateHTTPTransport(); err != nil { + return err + } channelOtherSettings := &dto.ChannelOtherSettings{} if channel.OtherSettings != "" { err := common.UnmarshalJsonStr(channel.OtherSettings, channelOtherSettings) diff --git a/model/channel_settings_test.go b/model/channel_settings_test.go index 7203671f..7612e697 100644 --- a/model/channel_settings_test.go +++ b/model/channel_settings_test.go @@ -9,6 +9,38 @@ import ( "github.com/stretchr/testify/require" ) +func TestChannelValidateSettingsRejectsInvalidHTTPTransport(t *testing.T) { + tests := []struct { + name string + setting dto.ChannelSettings + wantErr string + }{ + { + name: "auto with shards is valid", + setting: dto.ChannelSettings{HTTPProtocol: "auto", HTTP2ConnectionShards: 4}, + }, + { + name: "http1 with shards greater than one rejected", + setting: dto.ChannelSettings{HTTPProtocol: "http1", HTTP2ConnectionShards: 2}, + wantErr: "http2_connection_shards", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := &Channel{} + channel.SetSetting(tt.setting) + err := channel.ValidateSettings() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + func TestAdvancedCustomChannelRequiresModelListRouteOnlyWhenUpdateChecksEnabled(t *testing.T) { inferenceRoute := dto.AdvancedCustomRoute{ IncomingPath: "/v1/chat/completions", diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index f6c79925..fc2855a8 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -475,15 +475,19 @@ func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return doRequest(c, req, info) } func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { - var client *http.Client - var err error - if info.ChannelSetting.Proxy != "" { - client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) - if err != nil { - return nil, fmt.Errorf("new proxy http client failed: %w", err) - } - } else { - client = service.GetHttpClient() + client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + if common2.DebugEnabled && req != nil && req.URL != nil { + policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting) + logger.LogDebug(c, fmt.Sprintf( + "http transport select: host=%s protocol=%s shards=%d policy=%s", + req.URL.Host, + policy.Protocol, + policy.Shards, + policy.String(), + )) } var stopPinger context.CancelFunc @@ -514,6 +518,17 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http if resp == nil { return nil, errors.New("resp is nil") } + if common2.DebugEnabled { + policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting) + logger.LogDebug(c, fmt.Sprintf( + "http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s", + req.URL.Host, + policy.Protocol, + policy.Shards, + policy.String(), + resp.Proto, + )) + } if upID := resp.Header.Get(common2.RequestIdKey); upID != "" { c.Set(common2.UpstreamRequestIdKey, upID) diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index e51927c1..c502364c 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -48,17 +48,9 @@ func newAwsInvokeContext() (context.Context, context.CancelFunc) { } func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) { - var ( - httpClient *http.Client - err error - ) - if info.ChannelSetting.Proxy != "" { - httpClient, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) - if err != nil { - return nil, fmt.Errorf("new proxy http client failed: %w", err) - } - } else { - httpClient = service.GetHttpClient() + httpClient, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) } awsSecret := strings.Split(info.ApiKey, "|") diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go index 62eeab85..923ec718 100644 --- a/relay/channel/coze/relay-coze.go +++ b/relay/channel/coze/relay-coze.go @@ -279,15 +279,9 @@ func getChatDetail(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (*ht } func doRequest(req *http.Request, info *relaycommon.RelayInfo) (*http.Response, error) { - var client *http.Client - var err error // 声明 err 变量 - if info.ChannelSetting.Proxy != "" { - client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) - if err != nil { - return nil, fmt.Errorf("new proxy http client failed: %w", err) - } - } else { - client = service.GetHttpClient() + client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) } resp, err := client.Do(req) if err != nil { // 增加对 client.Do(req) 返回错误的检查 diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go index 6b077f55..6d5fee0d 100644 --- a/relay/channel/vertex/service_account.go +++ b/relay/channel/vertex/service_account.go @@ -111,15 +111,9 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") data.Set("assertion", signedJWT) - var client *http.Client - var err error - if info.ChannelSetting.Proxy != "" { - client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) - if err != nil { - return "", fmt.Errorf("new proxy http client failed: %w", err) - } - } else { - client = service.GetHttpClient() + client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting) + if err != nil { + return "", fmt.Errorf("new proxy http client failed: %w", err) } resp, err := client.PostForm(authURL, data) diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go index 3d7937d3..d3ede20d 100644 --- a/relaykit/dto/channel_settings.go +++ b/relaykit/dto/channel_settings.go @@ -17,6 +17,38 @@ type ChannelSettings struct { PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` SystemPromptOverride bool `json:"system_prompt_override,omitempty"` + // HTTPProtocol controls outbound HTTP version negotiation for this channel. + // Accepted values: "", "auto" (default), "http1". + HTTPProtocol string `json:"http_protocol,omitempty"` + // HTTP2ConnectionShards spreads HTTP/2 traffic across N independent transports + // (1-8). Zero/unset means 1. Ignored when HTTPProtocol is "http1". + HTTP2ConnectionShards int `json:"http2_connection_shards,omitempty"` +} + +const ( + HTTPProtocolAuto = "auto" + HTTPProtocolHTTP1 = "http1" + MaxHTTP2ConnectionShards = 8 +) + +// ValidateHTTPTransport validates save-time HTTP transport channel settings. +func (s *ChannelSettings) ValidateHTTPTransport() error { + if s == nil { + return nil + } + protocol := strings.ToLower(strings.TrimSpace(s.HTTPProtocol)) + switch protocol { + case "", HTTPProtocolAuto, HTTPProtocolHTTP1: + default: + return fmt.Errorf("invalid http_protocol: %s", s.HTTPProtocol) + } + if s.HTTP2ConnectionShards < 0 || s.HTTP2ConnectionShards > MaxHTTP2ConnectionShards { + return fmt.Errorf("invalid http2_connection_shards: %d", s.HTTP2ConnectionShards) + } + if protocol == HTTPProtocolHTTP1 && s.HTTP2ConnectionShards > 1 { + return fmt.Errorf("http2_connection_shards must be 1 when http_protocol is http1") + } + return nil } type VertexKeyType string diff --git a/relaykit/dto/channel_settings_test.go b/relaykit/dto/channel_settings_test.go index 90cc6fff..d482679a 100644 --- a/relaykit/dto/channel_settings_test.go +++ b/relaykit/dto/channel_settings_test.go @@ -1,6 +1,7 @@ package dto import ( + "encoding/json" "regexp" "testing" @@ -519,3 +520,61 @@ func TestAdvancedCustomValidateAlphaSearchConverterPath(t *testing.T) { }) } } + +func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) { + legacy := `{"proxy":"http://127.0.0.1:8080","force_format":true}` + var settings ChannelSettings + require.NoError(t, json.Unmarshal([]byte(legacy), &settings)) + assert.Equal(t, "http://127.0.0.1:8080", settings.Proxy) + assert.True(t, settings.ForceFormat) + assert.Empty(t, settings.HTTPProtocol) + assert.Zero(t, settings.HTTP2ConnectionShards) + + encoded, err := json.Marshal(settings) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "http_protocol") + assert.NotContains(t, string(encoded), "http2_connection_shards") + + explicit := ChannelSettings{ + Proxy: "socks5://127.0.0.1:1080", + HTTPProtocol: HTTPProtocolHTTP1, + HTTP2ConnectionShards: 1, + } + encoded, err = json.Marshal(explicit) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"http_protocol":"http1"`) + + var decoded ChannelSettings + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol) + assert.Equal(t, 1, decoded.HTTP2ConnectionShards) + + sharded := ChannelSettings{HTTP2ConnectionShards: 4} + encoded, err = json.Marshal(sharded) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"http2_connection_shards":4`) + assert.NotContains(t, string(encoded), "http_protocol") +} + +func TestChannelSettingsValidateHTTPTransport(t *testing.T) { + require.NoError(t, (&ChannelSettings{}).ValidateHTTPTransport()) + require.NoError(t, (&ChannelSettings{HTTPProtocol: "AUTO"}).ValidateHTTPTransport()) + require.NoError(t, (&ChannelSettings{HTTPProtocol: "http1"}).ValidateHTTPTransport()) + require.NoError(t, (&ChannelSettings{HTTP2ConnectionShards: 8}).ValidateHTTPTransport()) + + err := (&ChannelSettings{HTTPProtocol: "http2"}).ValidateHTTPTransport() + require.Error(t, err) + assert.Contains(t, err.Error(), "http_protocol") + + err = (&ChannelSettings{HTTP2ConnectionShards: -1}).ValidateHTTPTransport() + require.Error(t, err) + assert.Contains(t, err.Error(), "http2_connection_shards") + + err = (&ChannelSettings{HTTP2ConnectionShards: 9}).ValidateHTTPTransport() + require.Error(t, err) + assert.Contains(t, err.Error(), "http2_connection_shards") + + err = (&ChannelSettings{HTTPProtocol: "http1", HTTP2ConnectionShards: 2}).ValidateHTTPTransport() + require.Error(t, err) + assert.Contains(t, err.Error(), "http2_connection_shards") +} diff --git a/service/http_client.go b/service/http_client.go index c6febe06..f5c19eaf 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto/tls" "fmt" "net" "net/http" @@ -12,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/setting/system_setting" "golang.org/x/net/proxy" @@ -30,7 +32,7 @@ var ( type proxyHTTPClientCache struct { mutex sync.RWMutex clients map[string]*http.Client - aliases map[string]string + aliases map[string]string // rawProxyURL -> canonicalProxyURL } type proxyURLConfig struct { @@ -96,7 +98,7 @@ func newRelayHTTPTransport() *http.Transport { return transport } -func newRelayHTTPClient(transport *http.Transport) *http.Client { +func newRelayHTTPClient(transport http.RoundTripper) *http.Client { client := &http.Client{ Transport: transport, CheckRedirect: checkRedirect, @@ -107,10 +109,14 @@ func newRelayHTTPClient(transport *http.Transport) *http.Client { return client } +func clientCacheKey(proxyCacheKey string, policy HTTPTransportPolicy) string { + return proxyCacheKey + "\x00" + policy.cacheKeyPart() +} + func InitHttpClient() { - transport := newRelayHTTPTransport() - transport.Proxy = http.ProxyFromEnvironment - httpClient = newRelayHTTPClient(transport) + policy := defaultHTTPTransportPolicy() + httpClient = newDirectHTTPClient(policy, nil) + proxyClients.store(clientCacheKey("", policy), httpClient) ssrfProtectedHTTPClient = newProtectedFetchHTTPClient() } @@ -177,45 +183,73 @@ func ValidateProxyURL(rawProxyURL string) error { return err } -func (cache *proxyHTTPClientCache) get(rawCacheKey string) (*http.Client, bool) { +func (cache *proxyHTTPClientCache) store(fullKey string, client *http.Client) { + cache.mutex.Lock() + defer cache.mutex.Unlock() + cache.clients[fullKey] = client +} + +func (cache *proxyHTTPClientCache) resolveProxyKey(rawProxyURL string) string { + if canonicalKey, ok := cache.aliases[rawProxyURL]; ok { + return canonicalKey + } + return rawProxyURL +} + +func (cache *proxyHTTPClientCache) get(rawProxyURL string, policy HTTPTransportPolicy) (*http.Client, bool) { cache.mutex.RLock() defer cache.mutex.RUnlock() - cacheKey := rawCacheKey - if canonicalKey, ok := cache.aliases[rawCacheKey]; ok { - cacheKey = canonicalKey - } - client, ok := cache.clients[cacheKey] + proxyKey := cache.resolveProxyKey(rawProxyURL) + client, ok := cache.clients[clientCacheKey(proxyKey, policy)] return client, ok } -func (cache *proxyHTTPClientCache) getOrCreate(rawCacheKey string, config *proxyURLConfig) (*http.Client, error) { +func (cache *proxyHTTPClientCache) getOrCreate( + rawProxyURL string, + config *proxyURLConfig, + policy HTTPTransportPolicy, + factory func() (*http.Client, error), +) (*http.Client, error) { cache.mutex.Lock() defer cache.mutex.Unlock() - if client, ok := cache.clients[config.cacheKey]; ok { - cache.aliases[rawCacheKey] = config.cacheKey + + proxyKey := "" + if config != nil { + proxyKey = config.cacheKey + cache.aliases[rawProxyURL] = proxyKey + } else if rawProxyURL != "" { + proxyKey = cache.resolveProxyKey(rawProxyURL) + } + fullKey := clientCacheKey(proxyKey, policy) + if client, ok := cache.clients[fullKey]; ok { return client, nil } - client, err := newProxyHTTPClient(config.parsedURL) + client, err := factory() if err != nil { return nil, err } - cache.clients[config.cacheKey] = client - cache.aliases[rawCacheKey] = config.cacheKey + cache.clients[fullKey] = client return client, nil } -func (cache *proxyHTTPClientCache) remove(cacheKey string) *http.Client { +func (cache *proxyHTTPClientCache) removeProxy(proxyCacheKey string) []*http.Client { cache.mutex.Lock() defer cache.mutex.Unlock() - client := cache.clients[cacheKey] - delete(cache.clients, cacheKey) + removed := make([]*http.Client, 0) + prefix := proxyCacheKey + "\x00" + for key, client := range cache.clients { + if strings.HasPrefix(key, prefix) { + removed = append(removed, client) + delete(cache.clients, key) + } + } for alias, canonicalKey := range cache.aliases { - if canonicalKey == cacheKey { + if canonicalKey == proxyCacheKey { delete(cache.aliases, alias) } } - return client + return removed } func (cache *proxyHTTPClientCache) reset() map[string]*http.Client { @@ -227,13 +261,11 @@ func (cache *proxyHTTPClientCache) reset() map[string]*http.Client { return oldClients } -func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) { - transport := newRelayHTTPTransport() - +func configureProxyTransport(transport *http.Transport, proxyURL *url.URL) error { switch proxyURL.Scheme { case "http", "https": transport.Proxy = http.ProxyURL(proxyURL) - + return nil case "socks5", "socks5h": transport.Proxy = nil forwardDialer := &net.Dialer{ @@ -242,31 +274,104 @@ func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) { } dialer, err := proxy.FromURL(proxyURL, forwardDialer) if err != nil { - return nil, err + return err } contextDialer, ok := dialer.(proxy.ContextDialer) if !ok { - return nil, fmt.Errorf("SOCKS proxy dialer does not support context cancellation") + return fmt.Errorf("SOCKS proxy dialer does not support context cancellation") } transport.DialContext = contextDialer.DialContext - + return nil default: - return nil, fmt.Errorf("unsupported proxy scheme") + return fmt.Errorf("unsupported proxy scheme") } +} - return newRelayHTTPClient(transport), nil +func newTransportFactory(proxyURL *url.URL, tlsConfig *tls.Config) (func() *http.Transport, error) { + // Validate proxy configuration once before creating shard transports. + if proxyURL != nil { + probe := newRelayHTTPTransport() + if err := configureProxyTransport(probe, proxyURL); err != nil { + return nil, err + } + } + return func() *http.Transport { + transport := newRelayHTTPTransport() + if proxyURL != nil { + _ = configureProxyTransport(transport, proxyURL) + } else { + transport.Proxy = http.ProxyFromEnvironment + } + if tlsConfig != nil { + transport.TLSClientConfig = tlsConfig.Clone() + } + return transport + }, nil +} + +func newHTTPClientFromPolicy(policy HTTPTransportPolicy, proxyURL *url.URL, tlsConfig *tls.Config) (*http.Client, error) { + factory, err := newTransportFactory(proxyURL, tlsConfig) + if err != nil { + return nil, err + } + return newHTTPClientFromTransportFactory(policy, factory), nil +} + +func newHTTPClientFromTransportFactory(policy HTTPTransportPolicy, factory func() *http.Transport) *http.Client { + if policy.Shards < 1 { + policy.Shards = 1 + } + if policy.Protocol == dto.HTTPProtocolHTTP1 || policy.Shards == 1 { + transport := factory() + applyHTTPTransportPolicy(transport, policy) + return newRelayHTTPClient(transport) + } + shardedFactory := func() *http.Transport { + transport := factory() + applyHTTPTransportPolicy(transport, policy) + return transport + } + return newRelayHTTPClient(newShardedRoundTripper(policy, shardedFactory)) +} + +func newDirectHTTPClient(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client { + client, err := newHTTPClientFromPolicy(policy, nil, tlsConfig) + if err != nil { + // Direct clients cannot fail proxy configuration. + transport := newRelayHTTPTransport() + applyHTTPTransportPolicy(transport, policy) + return newRelayHTTPClient(transport) + } + return client +} + +// newHTTPClientWithPolicyAndTLS is a test seam that builds a never-used transport +// stack with the given policy and TLS config (for httptest certificate trust). +func newHTTPClientWithPolicyAndTLS(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client { + return newDirectHTTPClient(policy, tlsConfig) +} + +func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) { + return newHTTPClientFromPolicy(defaultHTTPTransportPolicy(), proxyURL, nil) } // GetHttpClientWithProxy returns the default client or a cached proxy-enabled client. func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) { + return GetHttpClientWithProxySettings(rawProxyURL, dto.ChannelSettings{}) +} + +// GetHttpClientWithProxySettings returns a cached HTTP client for the proxy URL and +// channel transport settings. Default auto + 1 shard shares the same client pool as +// GetHttpClientWithProxy / GetHttpClient for the empty-proxy case. +func GetHttpClientWithProxySettings(rawProxyURL string, settings dto.ChannelSettings) (*http.Client, error) { + policy := NormalizeHTTPTransportPolicy(settings) trimmedProxyURL := strings.TrimSpace(rawProxyURL) + if trimmedProxyURL == "" { - if client := GetHttpClient(); client != nil { - return client, nil - } - return http.DefaultClient, nil + return getOrCreateDirectClient(policy) } - if client, ok := proxyClients.get(trimmedProxyURL); ok { + + if client, ok := proxyClients.get(trimmedProxyURL, policy); ok { return client, nil } @@ -278,10 +383,31 @@ func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) { if legacySuffixStripped { warnLegacyProxyURLOnce(config) } - return proxyClients.getOrCreate(trimmedProxyURL, config) + return proxyClients.getOrCreate(trimmedProxyURL, config, policy, func() (*http.Client, error) { + return newHTTPClientFromPolicy(policy, config.parsedURL, nil) + }) } -// InvalidateProxyClient removes one proxy client and closes its idle connections. +func getOrCreateDirectClient(policy HTTPTransportPolicy) (*http.Client, error) { + defaultPolicy := defaultHTTPTransportPolicy() + if policy == defaultPolicy { + if client := GetHttpClient(); client != nil { + return client, nil + } + // Compatibility with pre-init callers: never assign httpClient outside InitHttpClient. + return http.DefaultClient, nil + } + + if client, ok := proxyClients.get("", policy); ok { + return client, nil + } + return proxyClients.getOrCreate("", nil, policy, func() (*http.Client, error) { + return newDirectHTTPClient(policy, nil), nil + }) +} + +// InvalidateProxyClient removes every cached policy variant for one proxy and +// closes their idle connections (including all HTTP/2 shards). func InvalidateProxyClient(rawProxyURL string) { parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL) if err != nil || parsedURL == nil { @@ -291,16 +417,26 @@ func InvalidateProxyClient(rawProxyURL string) { if legacySuffixStripped { warnLegacyProxyURLOnce(config) } - if client := proxyClients.remove(config.cacheKey); client != nil { + for _, client := range proxyClients.removeProxy(config.cacheKey) { client.CloseIdleConnections() } } -// ResetProxyClientCache clears all cached proxy clients. +// ResetProxyClientCache clears cached proxy and non-default direct policy clients +// and closes idle connections on every transport/shard. The package-level default +// httpClient pointer stays stable after InitHttpClient; it is only closed and +// re-registered in the policy cache so concurrent GetHttpClient readers never race +// a pointer replacement. func ResetProxyClientCache() { + defaultClient := httpClient for _, client := range proxyClients.reset() { client.CloseIdleConnections() } + if defaultClient == nil { + return + } + defaultClient.CloseIdleConnections() + proxyClients.store(clientCacheKey("", defaultHTTPTransportPolicy()), defaultClient) } // NewProxyHttpClient is kept for compatibility. diff --git a/service/http_client_transport_test.go b/service/http_client_transport_test.go new file mode 100644 index 00000000..b2f83a1a --- /dev/null +++ b/service/http_client_transport_test.go @@ -0,0 +1,491 @@ +package service + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withRelayHTTPTransportSettings(t *testing.T) { + t.Helper() + prevMaxIdle := common.RelayMaxIdleConns + prevPerHost := common.RelayMaxIdleConnsPerHost + prevTimeout := common.RelayIdleConnTimeout + common.RelayMaxIdleConns = 500 + common.RelayMaxIdleConnsPerHost = 100 + common.RelayIdleConnTimeout = 90 + t.Cleanup(func() { + common.RelayMaxIdleConns = prevMaxIdle + common.RelayMaxIdleConnsPerHost = prevPerHost + common.RelayIdleConnTimeout = prevTimeout + }) +} + +func initDefaultHTTPClientFixture(t *testing.T) *http.Client { + t.Helper() + withRelayHTTPTransportSettings(t) + if httpClient == nil { + InitHttpClient() + } else { + ResetProxyClientCache() + } + require.NotNil(t, httpClient) + t.Cleanup(ResetProxyClientCache) + return httpClient +} + +func TestShardedRoundTripperPerOriginRotation(t *testing.T) { + s := &shardedRoundTripper{n: 4} + originA := "https://a.example:443" + originB := "https://b.example:443" + + gotA := make([]uint32, 0, 8) + for i := 0; i < 8; i++ { + gotA = append(gotA, s.pickShard(originA)) + } + assert.Equal(t, []uint32{0, 1, 2, 3, 0, 1, 2, 3}, gotA) + + gotB := make([]uint32, 0, 4) + for i := 0; i < 4; i++ { + gotB = append(gotB, s.pickShard(originB)) + } + assert.Equal(t, []uint32{0, 1, 2, 3}, gotB, "independent origins must have independent counters") + + var wg sync.WaitGroup + const workers = 32 + const perWorker = 50 + var badShardCount atomic.Uint32 + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for j := 0; j < perWorker; j++ { + idx := s.pickShard(originA) + if idx >= 4 { + badShardCount.Add(1) + } + } + }() + } + wg.Wait() + assert.Equal(t, uint32(0), badShardCount.Load()) +} + +func TestOriginKeyUsesSchemeAndHost(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "HTTPS://Example.COM:8443/path", nil) + assert.Equal(t, "https://Example.COM:8443", originKey(req)) +} + +func testTLSClientConfig(t *testing.T, server *httptest.Server) *tls.Config { + t.Helper() + pool := x509.NewCertPool() + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + require.True(t, pool.AppendCertsFromPEM(certPEM)) + return &tls.Config{RootCAs: pool} +} + +func startHTTP2TLSServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewUnstartedServer(handler) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +func drainClose(t *testing.T, resp *http.Response) { + t.Helper() + require.NotNil(t, resp) + _, err := io.Copy(io.Discard, resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +func TestAutoOneShardNegotiatesHTTP2SingleConnection(t *testing.T) { + withRelayHTTPTransportSettings(t) + + var mu sync.Mutex + addrs := make(map[string]struct{}) + var sawHTTP2 atomic.Bool + + server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + addrs[r.RemoteAddr] = struct{}{} + mu.Unlock() + if r.ProtoMajor == 2 { + sawHTTP2.Store(true) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server)) + for i := 0; i < 4; i++ { + resp, err := client.Get(server.URL) + require.NoError(t, err) + assert.Equal(t, 2, resp.ProtoMajor) + drainClose(t, resp) + } + + mu.Lock() + defer mu.Unlock() + assert.True(t, sawHTTP2.Load()) + assert.Len(t, addrs, 1, "auto+1 must reuse a single HTTP/2 connection") +} + +func TestFourShardHTTP2ReusesExactlyFourConnections(t *testing.T) { + withRelayHTTPTransportSettings(t) + + var mu sync.Mutex + addrs := make(map[string]struct{}) + var nonHTTP2Count atomic.Uint32 + + server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + addrs[r.RemoteAddr] = struct{}{} + mu.Unlock() + if r.ProtoMajor != 2 { + nonHTTP2Count.Add(1) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 4} + client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server)) + for i := 0; i < 8; i++ { + resp, err := client.Get(server.URL) + require.NoError(t, err) + assert.Equal(t, 2, resp.ProtoMajor) + drainClose(t, resp) + } + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, uint32(0), nonHTTP2Count.Load()) + assert.Len(t, addrs, 4, "four shards must establish and reuse exactly four connections") +} + +func TestForcedHTTP1AgainstHTTP2Server(t *testing.T) { + withRelayHTTPTransportSettings(t) + + var nonHTTP1Count atomic.Uint32 + server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor != 1 { + nonHTTP1Count.Add(1) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1} + client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server)) + resp, err := client.Get(server.URL) + require.NoError(t, err) + assert.Equal(t, 1, resp.ProtoMajor) + drainClose(t, resp) + assert.Equal(t, uint32(0), nonHTTP1Count.Load()) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + assert.False(t, transport.DisableKeepAlives) + assert.False(t, transport.ForceAttemptHTTP2) + assert.NotNil(t, transport.TLSNextProto) + assert.Len(t, transport.TLSNextProto, 0) +} + +func TestForcedHTTP1ConcurrentDistinctConnections(t *testing.T) { + withRelayHTTPTransportSettings(t) + + const k = 8 + var mu sync.Mutex + addrs := make(map[string]struct{}) + arrived := make(chan struct{}, k) + release := make(chan struct{}) + var nonHTTP1Count atomic.Uint32 + + server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + addrs[r.RemoteAddr] = struct{}{} + mu.Unlock() + if r.ProtoMajor != 1 { + nonHTTP1Count.Add(1) + } + arrived <- struct{}{} + <-release + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1} + client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server)) + + errCh := make(chan error, k) + for i := 0; i < k; i++ { + go func() { + resp, err := client.Get(server.URL) + if err != nil { + errCh <- err + return + } + if resp.ProtoMajor != 1 { + errCh <- fmt.Errorf("expected HTTP/1.x, got %s", resp.Proto) + _ = resp.Body.Close() + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + errCh <- nil + }() + } + + for i := 0; i < k; i++ { + <-arrived + } + mu.Lock() + activeAddrs := len(addrs) + mu.Unlock() + close(release) + + for i := 0; i < k; i++ { + require.NoError(t, <-errCh) + } + assert.Equal(t, uint32(0), nonHTTP1Count.Load()) + assert.Equal(t, k, activeAddrs, "all HTTP/1.1 handlers active together must use K distinct connections") +} + +func TestHTTPClientCachePolicyAndCompatibility(t *testing.T) { + defaultClient := initDefaultHTTPClientFixture(t) + + compat, err := GetHttpClientWithProxy("") + require.NoError(t, err) + aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{}) + require.NoError(t, err) + assert.Same(t, defaultClient, compat) + assert.Same(t, compat, aware) + assert.Same(t, GetHttpClient(), aware) + + http1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + assert.NotSame(t, aware, http1) + + sharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 4}) + require.NoError(t, err) + assert.NotSame(t, aware, sharded) + assert.NotSame(t, http1, sharded) + + proxyA := "http://proxy.example:8080" + proxyAlias := "http://proxy.example:8080/" + clientA, err := GetHttpClientWithProxy(proxyA) + require.NoError(t, err) + clientAlias, err := GetHttpClientWithProxy(proxyAlias) + require.NoError(t, err) + assert.Same(t, clientA, clientAlias, "canonical proxy aliases must share the default policy client") + + proxyHTTP1, err := GetHttpClientWithProxySettings(proxyA, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + assert.NotSame(t, clientA, proxyHTTP1) +} + +func TestHTTPClientCacheConcurrentGetOrCreate(t *testing.T) { + initDefaultHTTPClientFixture(t) + + proxyURL := "http://concurrent-proxy.example:9090" + const workers = 32 + results := make([]*http.Client, workers) + errs := make([]error, workers) + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + i := i + go func() { + defer wg.Done() + client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 3}) + errs[i] = err + results[i] = client + }() + } + wg.Wait() + for i := 0; i < workers; i++ { + require.NoError(t, errs[i]) + } + for i := 1; i < workers; i++ { + assert.Same(t, results[0], results[i]) + } +} + +type closeCountingRoundTripper struct { + closes atomic.Int32 +} + +func (c *closeCountingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Header: make(http.Header), + Request: req, + }, nil +} + +func (c *closeCountingRoundTripper) CloseIdleConnections() { + c.closes.Add(1) +} + +func TestShardedRoundTripperCloseIdleConnectionsFansOut(t *testing.T) { + trackers := []*closeCountingRoundTripper{{}, {}, {}} + shards := make([]http.RoundTripper, len(trackers)) + for i, tracker := range trackers { + shards[i] = tracker + } + s := &shardedRoundTripper{shards: shards, n: uint32(len(shards))} + s.CloseIdleConnections() + for _, tracker := range trackers { + assert.Equal(t, int32(1), tracker.closes.Load()) + } +} + +func TestInvalidateProxyClientClosesAllPolicyVariants(t *testing.T) { + initDefaultHTTPClientFixture(t) + + proxyURL := "http://invalidate-proxy.example:8080" + defaultClient, err := GetHttpClientWithProxy(proxyURL) + require.NoError(t, err) + http1Client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + shardedClient, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2}) + require.NoError(t, err) + + InvalidateProxyClient(proxyURL) + + afterDefault, err := GetHttpClientWithProxy(proxyURL) + require.NoError(t, err) + afterHTTP1, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + afterSharded, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2}) + require.NoError(t, err) + + assert.NotSame(t, defaultClient, afterDefault) + assert.NotSame(t, http1Client, afterHTTP1) + assert.NotSame(t, shardedClient, afterSharded) +} + +func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing.T) { + defaultClient := initDefaultHTTPClientFixture(t) + + http1Client, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + shardedClient, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3}) + require.NoError(t, err) + proxyClient, err := GetHttpClientWithProxy("http://reset-proxy.example:8080") + require.NoError(t, err) + + ResetProxyClientCache() + + assert.Same(t, defaultClient, GetHttpClient(), "default httpClient pointer must stay stable across reset") + aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{}) + require.NoError(t, err) + assert.Same(t, defaultClient, aware) + + afterHTTP1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}) + require.NoError(t, err) + afterSharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3}) + require.NoError(t, err) + afterProxy, err := GetHttpClientWithProxy("http://reset-proxy.example:8080") + require.NoError(t, err) + assert.NotSame(t, http1Client, afterHTTP1) + assert.NotSame(t, shardedClient, afterSharded) + assert.NotSame(t, proxyClient, afterProxy) +} + +func TestResetProxyClientCacheClosesDefaultIdlePool(t *testing.T) { + defaultClient := initDefaultHTTPClientFixture(t) + tracker := &closeCountingRoundTripper{} + previousTransport := defaultClient.Transport + defaultClient.Transport = tracker + t.Cleanup(func() { + defaultClient.Transport = previousTransport + }) + + ResetProxyClientCache() + + assert.Same(t, defaultClient, GetHttpClient()) + assert.GreaterOrEqual(t, tracker.closes.Load(), int32(1), "reset must close idle connections on the stable default client") + aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{}) + require.NoError(t, err) + assert.Same(t, defaultClient, aware) +} + +func TestResetProxyClientCacheConcurrentWithGetHttpClient(t *testing.T) { + initDefaultHTTPClientFixture(t) + + const workers = 64 + var wg sync.WaitGroup + wg.Add(workers * 2) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + _ = GetHttpClient() + }() + go func() { + defer wg.Done() + ResetProxyClientCache() + }() + } + wg.Wait() + assert.NotNil(t, GetHttpClient()) + aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{}) + require.NoError(t, err) + assert.Same(t, GetHttpClient(), aware) +} + +func TestCloseIdleConnectionsRedialsHTTP2(t *testing.T) { + withRelayHTTPTransportSettings(t) + + var mu sync.Mutex + addrs := make([]string, 0, 2) + + server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + addrs = append(addrs, r.RemoteAddr) + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server)) + resp, err := client.Get(server.URL) + require.NoError(t, err) + drainClose(t, resp) + + client.CloseIdleConnections() + + resp, err = client.Get(server.URL) + require.NoError(t, err) + drainClose(t, resp) + + mu.Lock() + defer mu.Unlock() + require.Len(t, addrs, 2) + assert.NotEqual(t, addrs[0], addrs[1], "after CloseIdleConnections the next request must redial") +} + +func TestNormalizeHTTPTransportPolicyClampsWithoutPanic(t *testing.T) { + assert.Equal(t, defaultHTTPTransportPolicy(), NormalizeHTTPTransportPolicy(dto.ChannelSettings{})) + assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "AUTO"})) + assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "HTTP1", HTTP2ConnectionShards: 8})) + assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "http3"})) + assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: -3})) + assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 8}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: 99})) +} diff --git a/service/http_transport_policy.go b/service/http_transport_policy.go new file mode 100644 index 00000000..c2e3d9a6 --- /dev/null +++ b/service/http_transport_policy.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relaykit/dto" +) + +// HTTPTransportPolicy is the runtime-normalized outbound HTTP transport policy +// for a channel. Unknown or out-of-range stored values are clamped safely. +type HTTPTransportPolicy struct { + Protocol string // dto.HTTPProtocolAuto or dto.HTTPProtocolHTTP1 + Shards int // 1..dto.MaxHTTP2ConnectionShards +} + +var httpTransportPolicyWarnings sync.Map + +func defaultHTTPTransportPolicy() HTTPTransportPolicy { + return HTTPTransportPolicy{ + Protocol: dto.HTTPProtocolAuto, + Shards: 1, + } +} + +// NormalizeHTTPTransportPolicy converts channel settings into a safe runtime policy. +// Invalid stored values never panic; they clamp to defaults and warn once per bad value. +func NormalizeHTTPTransportPolicy(settings dto.ChannelSettings) HTTPTransportPolicy { + policy := defaultHTTPTransportPolicy() + + protocol := strings.ToLower(strings.TrimSpace(settings.HTTPProtocol)) + switch protocol { + case "", dto.HTTPProtocolAuto: + policy.Protocol = dto.HTTPProtocolAuto + case dto.HTTPProtocolHTTP1: + policy.Protocol = dto.HTTPProtocolHTTP1 + default: + warnHTTPTransportPolicyOnce("http_protocol", settings.HTTPProtocol) + policy.Protocol = dto.HTTPProtocolAuto + } + + shards := settings.HTTP2ConnectionShards + switch { + case shards == 0: + policy.Shards = 1 + case shards < 1: + warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards)) + policy.Shards = 1 + case shards > dto.MaxHTTP2ConnectionShards: + warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards)) + policy.Shards = dto.MaxHTTP2ConnectionShards + default: + policy.Shards = shards + } + + if policy.Protocol == dto.HTTPProtocolHTTP1 { + if settings.HTTP2ConnectionShards > 1 { + warnHTTPTransportPolicyOnce( + "http_protocol+http2_connection_shards", + fmt.Sprintf("%s+%d", dto.HTTPProtocolHTTP1, settings.HTTP2ConnectionShards), + ) + } + policy.Shards = 1 + } + if policy.Shards < 1 { + policy.Shards = 1 + } + return policy +} + +func warnHTTPTransportPolicyOnce(field, value string) { + key := field + "=" + value + if _, loaded := httpTransportPolicyWarnings.LoadOrStore(key, struct{}{}); loaded { + return + } + logger.LogWarn( + context.Background(), + fmt.Sprintf("invalid channel http transport setting clamped: %s=%q", field, value), + ) +} + +func (p HTTPTransportPolicy) cacheKeyPart() string { + return fmt.Sprintf("%s|%d", p.Protocol, p.Shards) +} + +func (p HTTPTransportPolicy) String() string { + return p.cacheKeyPart() +} diff --git a/service/http_transport_sharded.go b/service/http_transport_sharded.go new file mode 100644 index 00000000..d557c581 --- /dev/null +++ b/service/http_transport_sharded.go @@ -0,0 +1,131 @@ +package service + +import ( + "crypto/tls" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relaykit/dto" +) + +// shardedRoundTripper fans requests for each origin across N independent +// transports so each origin can keep N reusable HTTP/2 connections. +type shardedRoundTripper struct { + shards []http.RoundTripper + n uint32 + policy HTTPTransportPolicy + counters sync.Map // origin -> *atomic.Uint32 +} + +func newShardedRoundTripper(policy HTTPTransportPolicy, factory func() *http.Transport) *shardedRoundTripper { + n := policy.Shards + if n < 1 { + n = 1 + } + shards := make([]http.RoundTripper, n) + for i := 0; i < n; i++ { + transport := factory() + transport.MaxIdleConns = max(1, transport.MaxIdleConns/n) + transport.MaxIdleConnsPerHost = max(1, transport.MaxIdleConnsPerHost/n) + shards[i] = transport + } + return &shardedRoundTripper{ + shards: shards, + n: uint32(n), + policy: policy, + } +} + +func originKey(req *http.Request) string { + if req == nil || req.URL == nil { + return "" + } + return strings.ToLower(req.URL.Scheme) + "://" + req.URL.Host +} + +func (s *shardedRoundTripper) pickShard(origin string) uint32 { + if s.n <= 1 { + return 0 + } + counterAny, _ := s.counters.LoadOrStore(origin, &atomic.Uint32{}) + counter := counterAny.(*atomic.Uint32) + return (counter.Add(1) - 1) % s.n +} + +func (s *shardedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + origin := originKey(req) + idx := s.pickShard(origin) + resp, err := s.shards[idx].RoundTrip(req) + if common.DebugEnabled { + proto := "" + if resp != nil { + proto = resp.Proto + } + host := "" + if req != nil && req.URL != nil { + host = req.URL.Host + } + logger.LogDebug( + req.Context(), + fmt.Sprintf( + "http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s", + host, + s.policy.Protocol, + idx, + s.n, + s.policy.cacheKeyPart(), + proto, + ), + ) + } + return resp, err +} + +func (s *shardedRoundTripper) CloseIdleConnections() { + for _, shard := range s.shards { + closeIdleConnections(shard) + } +} + +func closeIdleConnections(rt http.RoundTripper) { + type idleCloser interface { + CloseIdleConnections() + } + if closer, ok := rt.(idleCloser); ok { + closer.CloseIdleConnections() + } +} + +// applyHTTP1Force disables automatic HTTP/2 on a never-used transport. +// ForceAttemptHTTP2=false alone is insufficient; a non-nil empty TLSNextProto +// map prevents net/http from wiring HTTP/2. +func applyHTTP1Force(transport *http.Transport) { + if transport == nil { + return + } + transport.ForceAttemptHTTP2 = false + transport.DisableKeepAlives = false + transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper) + if transport.TLSClientConfig != nil { + cfg := transport.TLSClientConfig.Clone() + cfg.NextProtos = nil + transport.TLSClientConfig = cfg + } +} + +func applyHTTPTransportPolicy(transport *http.Transport, policy HTTPTransportPolicy) { + if transport == nil { + return + } + if policy.Protocol == dto.HTTPProtocolHTTP1 { + applyHTTP1Force(transport) + return + } + transport.ForceAttemptHTTP2 = true + transport.DisableKeepAlives = false +} diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index ff37fbd3..3380d9e5 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -284,6 +284,8 @@ const SENSITIVE_FORM_FIELDS = [ 'force_format', 'thinking_to_content', 'proxy', + 'http_protocol', + 'http2_connection_shards', 'pass_through_body_enabled', 'system_prompt', 'system_prompt_override', @@ -339,6 +341,9 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.thinking_to_content || values.pass_through_body_enabled || values.system_prompt_override || + (values.http_protocol && values.http_protocol !== 'auto') || + (values.http2_connection_shards != null && + values.http2_connection_shards > 1) || values.claude_beta_query || values.upstream_model_update_check_enabled || values.upstream_model_update_auto_sync_enabled || @@ -745,6 +750,8 @@ export function ChannelMutateDrawer({ 'disable_task_polling_sleep' ) const currentProxy = form.watch('proxy') + const currentHttpProtocol = form.watch('http_protocol') + const currentHttp2ConnectionShards = form.watch('http2_connection_shards') const currentSystemPrompt = form.watch('system_prompt') const currentSystemPromptOverride = form.watch('system_prompt_override') const currentAllowServiceTier = form.watch('allow_service_tier') @@ -1014,7 +1021,9 @@ export function ChannelMutateDrawer({ currentDisableTaskPollingSleep || currentProxy?.trim() || currentSystemPrompt?.trim() || - currentSystemPromptOverride + currentSystemPromptOverride || + (currentHttpProtocol && currentHttpProtocol !== 'auto') || + (currentHttp2ConnectionShards != null && currentHttp2ConnectionShards > 1) ) let fieldPassthroughConfigured = false if (currentType === 1 || currentType === 57) { @@ -4185,6 +4194,129 @@ export function ChannelMutateDrawer({ )} /> + ( + + {t('HTTP Protocol')} + + + {t( + 'Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.' + )} + + + + )} + /> + + { + const http1Selected = + currentHttpProtocol === 'http1' + const shardItems = Array.from( + { length: 8 }, + (_, index) => { + const value = String(index + 1) + return { value, label: value } + } + ) + return ( + + + {t('HTTP/2 Connection Shards')} + + + + {http1Selected + ? t( + 'HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.' + ) + : t( + 'Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).' + )} + + + + ) + }} + /> + >([ 'thinking_to_content', 'pass_through_body_enabled', 'proxy', + 'http_protocol', + 'http2_connection_shards', 'system_prompt', 'system_prompt_override', 'allow_service_tier', diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index f7a91b25..22f07931 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -70,6 +70,37 @@ function isOptionalProxyURL(value: string | undefined): boolean { } } +export const HTTP_PROTOCOL_AUTO = 'auto' +export const HTTP_PROTOCOL_HTTP1 = 'http1' +export const MAX_HTTP2_CONNECTION_SHARDS = 8 + +export function normalizeHttpProtocol( + value: string | undefined | null +): 'auto' | 'http1' { + const normalized = String(value || '') + .trim() + .toLowerCase() + if (normalized === HTTP_PROTOCOL_HTTP1) { + return HTTP_PROTOCOL_HTTP1 + } + return HTTP_PROTOCOL_AUTO +} + +export function normalizeHttp2ConnectionShards( + value: number | undefined | null +): number { + if (value == null || Number.isNaN(value) || value === 0) { + return 1 + } + if (value < 1) { + return 1 + } + if (value > MAX_HTTP2_CONNECTION_SHARDS) { + return MAX_HTTP2_CONNECTION_SHARDS + } + return value +} + function parseOptionalJson(value: string | undefined): unknown { if (!value?.trim()) return undefined return JSON.parse(value) @@ -225,6 +256,8 @@ export const channelFormSchema = z .string() .optional() .refine(isOptionalProxyURL, ERROR_MESSAGES.INVALID_PROXY), + http_protocol: z.enum(['auto', 'http1']).optional(), + http2_connection_shards: z.number().int().optional(), pass_through_body_enabled: z.boolean().optional(), system_prompt: z.string().optional(), system_prompt_override: z.boolean().optional(), @@ -340,6 +373,23 @@ export const channelFormSchema = z 'Vertex AI API Key mode does not support batch creation' ) } + + const protocol = normalizeHttpProtocol(data.http_protocol) + const shards = data.http2_connection_shards ?? 1 + if (shards < 1 || shards > MAX_HTTP2_CONNECTION_SHARDS) { + addRequiredIssue( + ctx, + 'http2_connection_shards', + ERROR_MESSAGES.INVALID_HTTP2_CONNECTION_SHARDS + ) + } + if (protocol === HTTP_PROTOCOL_HTTP1 && shards > 1) { + addRequiredIssue( + ctx, + 'http2_connection_shards', + ERROR_MESSAGES.INVALID_HTTP1_WITH_SHARDS + ) + } }) export type ChannelFormValues = z.infer @@ -378,6 +428,8 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { force_format: false, thinking_to_content: false, proxy: '', + http_protocol: HTTP_PROTOCOL_AUTO, + http2_connection_shards: 1, pass_through_body_enabled: false, system_prompt: '', system_prompt_override: false, @@ -416,6 +468,8 @@ export function transformChannelToFormDefaults( force_format: false, thinking_to_content: false, proxy: '', + http_protocol: HTTP_PROTOCOL_AUTO as 'auto' | 'http1', + http2_connection_shards: 1, pass_through_body_enabled: false, system_prompt: '', system_prompt_override: false, @@ -424,10 +478,17 @@ export function transformChannelToFormDefaults( if (channel.setting) { try { const parsed = JSON.parse(channel.setting) + const protocol = normalizeHttpProtocol(parsed.http_protocol) + const shards = normalizeHttp2ConnectionShards( + parsed.http2_connection_shards + ) extraSettings = { force_format: parsed.force_format || false, thinking_to_content: parsed.thinking_to_content || false, proxy: parsed.proxy || '', + http_protocol: protocol, + http2_connection_shards: + protocol === HTTP_PROTOCOL_HTTP1 ? 1 : shards, pass_through_body_enabled: parsed.pass_through_body_enabled || false, system_prompt: parsed.system_prompt || '', system_prompt_override: parsed.system_prompt_override || false, @@ -540,8 +601,8 @@ export function transformChannelToFormDefaults( /** * Build the setting JSON string from form extra settings */ -function buildSettingJSON(formData: ChannelFormValues): string { - const settingObj = { +export function buildSettingJSON(formData: ChannelFormValues): string { + const settingObj: Record = { force_format: formData.force_format || false, thinking_to_content: formData.thinking_to_content || false, proxy: formData.proxy?.trim() || '', @@ -549,6 +610,20 @@ function buildSettingJSON(formData: ChannelFormValues): string { system_prompt: formData.system_prompt || '', system_prompt_override: formData.system_prompt_override || false, } + + const protocol = normalizeHttpProtocol(formData.http_protocol) + const shards = + protocol === HTTP_PROTOCOL_HTTP1 + ? 1 + : normalizeHttp2ConnectionShards(formData.http2_connection_shards) + + // Omit defaults so unchanged channels keep equivalent JSON. + if (protocol === HTTP_PROTOCOL_HTTP1) { + settingObj.http_protocol = HTTP_PROTOCOL_HTTP1 + } else if (shards > 1) { + settingObj.http2_connection_shards = shards + } + return JSON.stringify(settingObj) } diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index 96bd5b2c..f7747fa2 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -86,6 +86,8 @@ export interface ChannelSettings { pass_through_body_enabled?: boolean system_prompt?: string system_prompt_override?: boolean + http_protocol?: 'auto' | 'http1' | string + http2_connection_shards?: number } export interface ChannelOtherSettings { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5c0ecd3b..cf55baae 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -503,6 +503,7 @@ "Auto Disabled": "Auto Disabled", "Auto group behavior": "Auto group behavior", "Auto Group Chain": "Auto Group Chain", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.", "Auto refresh": "Auto refresh", "Auto Sync Upstream Models": "Auto Sync Upstream Models", "Auto-disable rules": "Auto-disable rules", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "How to reset my quota?", "How to select keys: random or sequential polling": "How to select keys: random or sequential polling", "How will you use the platform?": "How will you use the platform?", + "HTTP Protocol": "HTTP Protocol", + "HTTP protocol must be Auto or HTTP/1.1": "HTTP protocol must be Auto or HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "HTTP/2 Connection Shards", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected", + "HTTP/2 connection shards must be between 1 and 8": "HTTP/2 connection shards must be between 1 and 8", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.", "Special visibility rules": "Special visibility rules", "Spend limited": "Spend limited", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF Protection", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index f7257863..eaea3e50 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -503,6 +503,7 @@ "Auto Disabled": "Désactivé automatiquement", "Auto group behavior": "Comportement du groupe auto", "Auto Group Chain": "Chaîne de groupes automatique", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Le mode Auto négocie HTTP/2 lorsque c’est disponible. HTTP/1.1 force plusieurs connexions keep-alive en concurrence.", "Auto refresh": "Actualisation automatique", "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", "Auto-disable rules": "Règles de désactivation automatique", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "Comment réinitialiser mon quota ?", "How to select keys: random or sequential polling": "Comment sélectionner les clés : sondage aléatoire ou séquentiel", "How will you use the platform?": "Comment allez-vous utiliser la plateforme ?", + "HTTP Protocol": "Protocole HTTP", + "HTTP protocol must be Auto or HTTP/1.1": "Le protocole HTTP doit être Auto ou HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "Fragments de connexion HTTP/2", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "Les fragments de connexion HTTP/2 sont indisponibles lorsque HTTP/1.1 est sélectionné.", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "Les fragments de connexion HTTP/2 doivent être 1 lorsque HTTP/1.1 est sélectionné", + "HTTP/2 connection shards must be between 1 and 8": "Les fragments de connexion HTTP/2 doivent être compris entre 1 et 8", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "Les règles de groupes utilisables spéciaux rendent des groupes de jetons supplémentaires visibles pour les utilisateurs d’un groupe donné, ou leur masquent des groupes par défaut.", "Special visibility rules": "Règles de visibilité spéciales", "Spend limited": "Dépenses limitées", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "Répartit le trafic HTTP/2 sur plusieurs connexions réutilisables vers la même origine amont (1-8).", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stocke toutes les données dans un seul fichier. Assurez-vous que ce fichier est persisté lors de l'exécution dans des conteneurs.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Protection SSRF", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 0048ee3c..782930c2 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -503,6 +503,7 @@ "Auto Disabled": "自動無効化", "Auto group behavior": "auto グループの動作", "Auto Group Chain": "自動グループチェーン", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自動は利用可能な場合に HTTP/2 を交渉します。HTTP/1.1 は同時実行時に複数のキープアライブ接続を使用します。", "Auto refresh": "自動更新", "Auto Sync Upstream Models": "アップストリームモデルの自動同期", "Auto-disable rules": "自動無効化ルール", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "クォータをリセットするには?", "How to select keys: random or sequential polling": "キーの選択方法: ランダムまたは順次ポーリング", "How will you use the platform?": "プラットフォームをどのように使用しますか?", + "HTTP Protocol": "HTTP プロトコル", + "HTTP protocol must be Auto or HTTP/1.1": "HTTP プロトコルは自動または HTTP/1.1 である必要があります", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "HTTP/2 接続シャード", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "HTTP/1.1 選択時は HTTP/2 接続シャードを使用できません。", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "HTTP/1.1 選択時、HTTP/2 接続シャードは 1 である必要があります", + "HTTP/2 connection shards must be between 1 and 8": "HTTP/2 接続シャードは 1 から 8 の間である必要があります", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "特殊利用可能グループルールにより、特定ユーザーグループのユーザーに追加のトークングループを表示したり、デフォルトのものを非表示にしたりできます。", "Special visibility rules": "特殊表示ルール", "Spend limited": "支出制限中", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "同一上流オリジンへの再利用可能な複数接続に HTTP/2 トラフィックを分散します(1-8)。", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite はすべてのデータを単一ファイルに保存します。コンテナで実行する場合は、ファイルが永続化されていることを確認してください。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF保護", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 4cd11458..5b72c8c5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -503,6 +503,7 @@ "Auto Disabled": "Автоматически отключено", "Auto group behavior": "Поведение группы auto", "Auto Group Chain": "Автоматическая цепочка групп", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Авто согласовывает HTTP/2 при наличии. HTTP/1.1 использует несколько keep-alive соединений при параллельных запросах.", "Auto refresh": "Автообновление", "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера", "Auto-disable rules": "Правила автоотключения", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "Как сбросить мою квоту?", "How to select keys: random or sequential polling": "Как выбирать ключи: случайно или последовательный опрос", "How will you use the platform?": "Как вы будете использовать платформу?", + "HTTP Protocol": "HTTP-протокол", + "HTTP protocol must be Auto or HTTP/1.1": "HTTP-протокол должен быть Auto или HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "Шарды соединений HTTP/2", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "Шарды соединений HTTP/2 недоступны при выборе HTTP/1.1.", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "При выборе HTTP/1.1 число шардов соединений HTTP/2 должно быть 1", + "HTTP/2 connection shards must be between 1 and 8": "Число шардов соединений HTTP/2 должно быть от 1 до 8", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "Правила особых доступных групп показывают дополнительные группы токенов пользователям определённой группы или скрывают от них группы по умолчанию.", "Special visibility rules": "Особые правила видимости", "Spend limited": "Ограничение расходов", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "Распределяет трафик HTTP/2 по нескольким переиспользуемым соединениям к одному upstream-источнику (1-8).", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite хранит все данные в одном файле. Убедитесь, что файл сохраняется при работе в контейнерах.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Защита от SSRF", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index e90175e3..b6cf06a5 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -503,6 +503,7 @@ "Auto Disabled": "Vô hiệu hóa tự động", "Auto group behavior": "Cách hoạt động của nhóm auto", "Auto Group Chain": "Chuỗi nhóm tự động", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Tự động đàm phán HTTP/2 khi khả dụng. HTTP/1.1 buộc dùng nhiều kết nối keep-alive khi có đồng thời.", "Auto refresh": "Tự động làm mới", "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", "Auto-disable rules": "Quy tắc tự động tắt", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "Cách đặt lại hạn mức của tôi?", "How to select keys: random or sequential polling": "Cách chọn khóa: thăm dò ngẫu nhiên hay tuần tự", "How will you use the platform?": "Bạn sẽ sử dụng nền tảng như thế nào?", + "HTTP Protocol": "Giao thức HTTP", + "HTTP protocol must be Auto or HTTP/1.1": "Giao thức HTTP phải là Tự động hoặc HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "Phân mảnh kết nối HTTP/2", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "Không thể dùng phân mảnh kết nối HTTP/2 khi chọn HTTP/1.1.", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "Khi chọn HTTP/1.1, phân mảnh kết nối HTTP/2 phải là 1", + "HTTP/2 connection shards must be between 1 and 8": "Phân mảnh kết nối HTTP/2 phải từ 1 đến 8", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "Quy tắc nhóm khả dụng đặc biệt hiển thị thêm nhóm token cho người dùng của một nhóm cụ thể, hoặc ẩn các nhóm mặc định khỏi họ.", "Special visibility rules": "Quy tắc hiển thị đặc biệt", "Spend limited": "Đã giới hạn chi tiêu", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "Phân tán lưu lượng HTTP/2 trên nhiều kết nối tái sử dụng tới cùng một nguồn upstream (1-8).", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite lưu trữ tất cả dữ liệu trong một tệp duy nhất. Đảm bảo tệp được lưu trữ lâu dài khi chạy trong container.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Bảo vệ SSRF", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 847069a2..48a169bd 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -503,6 +503,7 @@ "Auto Disabled": "自動停用", "Auto group behavior": "自動分組行為", "Auto Group Chain": "自動分組鏈", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自動在可用時協商 HTTP/2。HTTP/1.1 會在並發時使用多條保持連線的連線。", "Auto refresh": "自動重新整理", "Auto Sync Upstream Models": "自動同步上游模型", "Auto-disable rules": "自動停用規則", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "如何重置我的配額?", "How to select keys: random or sequential polling": "金鑰選擇方式:隨機或順序輪詢", "How will you use the platform?": "您將如何使用本平台?", + "HTTP Protocol": "HTTP 協定", + "HTTP protocol must be Auto or HTTP/1.1": "HTTP 協定必須是自動或 HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "HTTP/2 連線分片", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "選擇 HTTP/1.1 時無法使用 HTTP/2 連線分片。", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "選擇 HTTP/1.1 時,HTTP/2 連線分片必須為 1", + "HTTP/2 connection shards must be between 1 and 8": "HTTP/2 連線分片必須在 1 到 8 之間", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "特殊可用分組規則可以讓特定用戶分組的用戶額外看到某些令牌分組,或對其屏蔽預設可選的令牌分組。", "Special visibility rules": "特殊可見性規則", "Spend limited": "消費受限", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "將 HTTP/2 流量分散到同一上游來源的多條可重用連線(1-8)。", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 將所有數據儲存在單個檔案中。在容器中執行時請確保該檔案已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保護", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 922bf81b..4f9c343d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -503,6 +503,7 @@ "Auto Disabled": "自动禁用", "Auto group behavior": "自动分组行为", "Auto Group Chain": "自动分组链", + "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自动在可用时协商 HTTP/2。HTTP/1.1 会在并发时使用多条保持连接的连接。", "Auto refresh": "自动刷新", "Auto Sync Upstream Models": "自动同步上游模型", "Auto-disable rules": "自动禁用规则", @@ -2226,6 +2227,13 @@ "How to reset my quota?": "如何重置我的配额?", "How to select keys: random or sequential polling": "密钥选择方式:随机或顺序轮询", "How will you use the platform?": "您将如何使用本平台?", + "HTTP Protocol": "HTTP 协议", + "HTTP protocol must be Auto or HTTP/1.1": "HTTP 协议必须是自动或 HTTP/1.1", + "HTTP/1.1": "HTTP/1.1", + "HTTP/2 Connection Shards": "HTTP/2 连接分片", + "HTTP/2 connection shards are unavailable when HTTP/1.1 is selected.": "选择 HTTP/1.1 时不可用 HTTP/2 连接分片。", + "HTTP/2 connection shards must be 1 when HTTP/1.1 is selected": "选择 HTTP/1.1 时,HTTP/2 连接分片必须为 1", + "HTTP/2 connection shards must be between 1 and 8": "HTTP/2 连接分片必须在 1 到 8 之间", "https://api.day.app/yourkey/{{title}}/{{content}}": "https://api.day.app/yourkey/{{title}}/{{content}}", "https://api.example.com": "https://api.example.com", "https://ark.ap-southeast.bytepluses.com": "https://ark.ap-southeast.bytepluses.com", @@ -4250,6 +4258,7 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "特殊可用分组规则可以让特定用户分组的用户额外看到某些令牌分组,或对其屏蔽默认可选的令牌分组。", "Special visibility rules": "特殊可见性规则", "Spend limited": "消费受限", + "Spread HTTP/2 traffic across multiple reusable connections to the same upstream origin (1-8).": "将 HTTP/2 流量分散到同一上游源站的多条可复用连接(1-8)。", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 将所有数据存储在单个文件中。在容器中运行时请确保该文件已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保护",