diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b5a7eb77..f57244fb 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, BackendModeEnabled: settings.BackendModeEnabled, + EnableFingerprintUnification: settings.EnableFingerprintUnification, + EnableMetadataPassthrough: settings.EnableMetadataPassthrough, }) } @@ -209,6 +211,10 @@ type UpdateSettingsRequest struct { // Backend Mode BackendModeEnabled bool `json:"backend_mode_enabled"` + + // Gateway forwarding behavior + EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` } // UpdateSettings 更新系统设置 @@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpsMetricsIntervalSeconds }(), + EnableFingerprintUnification: func() bool { + if req.EnableFingerprintUnification != nil { + return *req.EnableFingerprintUnification + } + return previousSettings.EnableFingerprintUnification + }(), + EnableMetadataPassthrough: func() bool { + if req.EnableMetadataPassthrough != nil { + return *req.EnableMetadataPassthrough + } + return previousSettings.EnableMetadataPassthrough + }(), } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, BackendModeEnabled: updatedSettings.BackendModeEnabled, + EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, + EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, }) } @@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.CustomMenuItems != after.CustomMenuItems { changed = append(changed, "custom_menu_items") } + if before.EnableFingerprintUnification != after.EnableFingerprintUnification { + changed = append(changed, "enable_fingerprint_unification") + } + if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough { + changed = append(changed, "enable_metadata_passthrough") + } return changed } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 7ea34aa0..59d7f688 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -94,6 +94,10 @@ type SystemSettings struct { // Backend Mode BackendModeEnabled bool `json:"backend_mode_enabled"` + + // Gateway forwarding behavior + EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/repository/claude_oauth_service.go b/backend/internal/repository/claude_oauth_service.go index b754bd55..fee5c645 100644 --- a/backend/internal/repository/claude_oauth_service.go +++ b/backend/internal/repository/claude_oauth_service.go @@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod SetContext(ctx). SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", "axios/1.8.4"). + SetHeader("User-Agent", "axios/1.13.6"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro SetContext(ctx). SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", "axios/1.8.4"). + SetHeader("User-Agent", "axios/1.13.6"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index a4674c1a..12523a91 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -1,6 +1,8 @@ package repository import ( + "compress/flate" + "compress/gzip" "errors" "fmt" "io" @@ -13,6 +15,8 @@ import ( "sync/atomic" "time" + "github.com/andybalholm/brotli" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" @@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i return nil, err } + // 如果上游返回了压缩内容,解压后再交给业务层 + decompressResponseBody(resp) + // 包装响应体,在关闭时自动减少计数并更新时间戳 // 这确保了流式响应(如 SSE)在完全读取前不会被淘汰 resp.Body = wrapTrackedBody(resp.Body, func() { @@ -218,6 +225,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode) + // 如果上游返回了压缩内容,解压后再交给业务层 + decompressResponseBody(resp) + // 包装响应体,在关闭时自动减少计数并更新时间戳 resp.Body = wrapTrackedBody(resp.Body, func() { atomic.AddInt64(&entry.inFlight, -1) @@ -884,3 +894,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser { } return &trackedBody{ReadCloser: body, onClose: onClose} } + +// decompressResponseBody 根据 Content-Encoding 解压响应体。 +// 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。 +// 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。 +func decompressResponseBody(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + ce := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) + if ce == "" { + return + } + + var reader io.Reader + switch ce { + case "gzip": + gr, err := gzip.NewReader(resp.Body) + if err != nil { + return // 解压失败,保持原样 + } + reader = gr + case "br": + reader = brotli.NewReader(resp.Body) + case "deflate": + reader = flate.NewReader(resp.Body) + default: + return + } + + originalBody := resp.Body + resp.Body = &decompressedBody{reader: reader, closer: originalBody} + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") // 解压后长度不确定 + resp.ContentLength = -1 +} + +// decompressedBody 组合解压 reader 和原始 body 的 close。 +type decompressedBody struct { + reader io.Reader + closer io.Closer +} + +func (d *decompressedBody) Read(p []byte) (int, error) { + return d.reader.Read(p) +} + +func (d *decompressedBody) Close() error { + // 如果 reader 本身也是 Closer(如 gzip.Reader),先关闭它 + if rc, ok := d.reader.(io.Closer); ok { + _ = rc.Close() + } + return d.closer.Close() +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 880fe8a0..ac4e05de 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) { "max_claude_code_version": "", "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, + "enable_fingerprint_unification": true, + "enable_metadata_passthrough": false, "custom_menu_items": [], "custom_endpoints": [] } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 4ae5a469..ecac0db0 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -235,6 +235,12 @@ const ( // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录 SettingKeyBackendModeEnabled = "backend_mode_enabled" + + // Gateway Forwarding Behavior + // SettingKeyEnableFingerprintUnification 是否统一 OAuth 账号的 X-Stainless-* 指纹头(默认 true) + SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification" + // SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false) + SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" ) // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index a01dd02a..f4e1b533 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -175,13 +175,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射") - require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key")) - require.Empty(t, upstream.lastReq.Header.Get("authorization")) - require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key")) - require.Empty(t, upstream.lastReq.Header.Get("cookie")) - require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version")) - require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta")) - require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头") + require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie")) + require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version")) + require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头") require.Contains(t, rec.Body.String(), `"cached_tokens":7`) require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写") @@ -257,9 +257,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo require.NoError(t, err) require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射") - require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key")) - require.Empty(t, upstream.lastReq.Header.Get("authorization")) - require.Empty(t, upstream.lastReq.Header.Get("cookie")) + require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie")) require.Equal(t, http.StatusOK, rec.Code) require.JSONEq(t, upstreamRespBody, rec.Body.String()) require.Empty(t, rec.Header().Get("Set-Cookie")) @@ -684,8 +684,8 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t req, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false) require.NoError(t, err) - require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization")) - require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") + require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization")) + require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") } func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { @@ -755,8 +755,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, upstream.lastReq) - require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization")) - require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth) + require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization")) + require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth) system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) diff --git a/backend/internal/service/gateway_debug_env_test.go b/backend/internal/service/gateway_debug_env_test.go index 4f48dc70..bd88a667 100644 --- a/backend/internal/service/gateway_debug_env_test.go +++ b/backend/internal/service/gateway_debug_env_test.go @@ -2,31 +2,28 @@ package service import "testing" -func TestDebugGatewayBodyLoggingEnabled(t *testing.T) { - t.Run("default disabled", func(t *testing.T) { - t.Setenv(debugGatewayBodyEnv, "") - if debugGatewayBodyLoggingEnabled() { - t.Fatalf("expected debug gateway body logging to be disabled by default") +func TestParseDebugEnvBool(t *testing.T) { + t.Run("empty is false", func(t *testing.T) { + if parseDebugEnvBool("") { + t.Fatalf("expected false for empty string") } }) - t.Run("enabled with true-like values", func(t *testing.T) { + t.Run("true-like values", func(t *testing.T) { for _, value := range []string{"1", "true", "TRUE", "yes", "on"} { t.Run(value, func(t *testing.T) { - t.Setenv(debugGatewayBodyEnv, value) - if !debugGatewayBodyLoggingEnabled() { - t.Fatalf("expected debug gateway body logging to be enabled for %q", value) + if !parseDebugEnvBool(value) { + t.Fatalf("expected true for %q", value) } }) } }) - t.Run("disabled with other values", func(t *testing.T) { + t.Run("false-like values", func(t *testing.T) { for _, value := range []string{"0", "false", "off", "debug"} { t.Run(value, func(t *testing.T) { - t.Setenv(debugGatewayBodyEnv, value) - if debugGatewayBodyLoggingEnabled() { - t.Fatalf("expected debug gateway body logging to be disabled for %q", value) + if parseDebugEnvBool(value) { + t.Fatalf("expected false for %q", value) } }) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 402975d7..ae66ae4a 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -13,6 +13,7 @@ import ( mathrand "math/rand" "net/http" "os" + "path/filepath" "regexp" "sort" "strconv" @@ -366,6 +367,7 @@ var allowedHeaders = map[string]bool{ "sec-fetch-mode": true, "user-agent": true, "content-type": true, + "accept-encoding": true, } // GatewayCache 定义网关服务的缓存操作接口。 @@ -563,6 +565,7 @@ type GatewayService struct { responseHeaderFilter *responseheaders.CompiledHeaderFilter debugModelRouting atomic.Bool debugClaudeMimic atomic.Bool + debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set } // NewGatewayService creates a new GatewayService @@ -630,6 +633,9 @@ func NewGatewayService( ) svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING"))) svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC"))) + if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" { + svc.initDebugGatewayBodyFile(path) + } return svc } @@ -4048,8 +4054,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A reqStream := parsed.Stream originalModel := reqModel - // === DEBUG: 打印客户端原始请求 body === - debugLogRequestBody("CLIENT_ORIGINAL", body) + // === DEBUG: 打印客户端原始请求(headers + body 摘要)=== + if c != nil { + s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{ + "account": fmt.Sprintf("%d(%s)", account.ID, account.Name), + "account_type": string(account.Type), + "model": reqModel, + "stream": strconv.FormatBool(reqStream), + }) + } isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode @@ -4066,9 +4079,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if s.identityService != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { - if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { - normalizeOpts.injectMetadata = true - normalizeOpts.metadataUserID = metadataUserID + // metadata 透传开启时跳过 metadata 注入 + _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx) + if !mimicMPT { + if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = metadataUserID + } } } } @@ -4840,8 +4857,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( if !allowedHeaders[lowerKey] { continue } + wireKey := resolveWireCasing(key) for _, v := range values { - req.Header.Add(key, v) + addHeaderRaw(req.Header, wireKey, v) } } } @@ -4851,13 +4869,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - req.Header.Set("x-api-key", token) + setHeaderRaw(req.Header, "x-api-key", token) - if req.Header.Get("content-type") == "" { - req.Header.Set("content-type", "application/json") + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") } - if req.Header.Get("anthropic-version") == "" { - req.Header.Set("anthropic-version", "2023-06-01") + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") } return req, nil @@ -5591,8 +5609,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex clientHeaders = c.Request.Header } - // OAuth账号:应用统一指纹 + // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) var fingerprint *Fingerprint + enableFP, enableMPT := true, false + if s.settingService != nil { + enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx) + } if account.IsOAuth() && s.identityService != nil { // 1. 获取或创建指纹(包含随机生成的ClientID) fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) @@ -5600,40 +5622,43 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err) // 失败时降级为透传原始headers } else { - fingerprint = fp + if enableFP { + fingerprint = fp + } // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid) // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - accountUUID := account.GetExtraString("account_uuid") - if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { - body = newBody + // 当 metadata 透传开启时跳过重写 + if !enableMPT { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" && fp.ClientID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } } } } } - // === DEBUG: 打印转发给上游的 body(metadata 已重写) === - debugLogRequestBody("UPSTREAM_FORWARD", body) - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err } - // 设置认证头 + // 设置认证头(保持原始大小写) if tokenType == "oauth" { - req.Header.Set("authorization", "Bearer "+token) + setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - req.Header.Set("x-api-key", token) + setHeaderRaw(req.Header, "x-api-key", token) } - // 白名单透传headers + // 白名单透传headers(恢复真实 wire casing) for key, values := range clientHeaders { lowerKey := strings.ToLower(key) if allowedHeaders[lowerKey] { + wireKey := resolveWireCasing(key) for _, v := range values { - req.Header.Add(key, v) + addHeaderRaw(req.Header, wireKey, v) } } } @@ -5643,15 +5668,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex s.identityService.ApplyFingerprint(req, fingerprint) } - // 确保必要的headers存在 - if req.Header.Get("content-type") == "" { - req.Header.Set("content-type", "application/json") + // 确保必要的headers存在(保持原始大小写) + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") } - if req.Header.Get("anthropic-version") == "" { - req.Header.Set("anthropic-version", "2023-06-01") + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") } if tokenType == "oauth" { - applyClaudeOAuthHeaderDefaults(req, reqStream) + applyClaudeOAuthHeaderDefaults(req) } // Build effective drop set: merge static defaults with dynamic beta policy filter rules @@ -5667,31 +5692,41 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在 applyClaudeCodeMimicHeaders(req, reqStream) - incomingBeta := req.Header.Get("anthropic-beta") + incomingBeta := getHeaderRaw(req.Header, "anthropic-beta") // Match real Claude CLI traffic (per mitmproxy reports): // messages requests typically use only oauth + interleaved-thinking. // Also drop claude-code beta if a downstream client added it. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet)) + setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet)) } else { // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta - clientBetaHeader := req.Header.Get("anthropic-beta") - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet)) + clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta") + setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet)) } } else { // API-key accounts: apply beta policy filter to strip controlled tokens - if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet)) + if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" { + setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet)) } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) if requestNeedsBetaFeatures(body) { if beta := defaultAPIKeyBetaHeader(body); beta != "" { - req.Header.Set("anthropic-beta", beta) + setHeaderRaw(req.Header, "anthropic-beta", beta) } } } } + // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 === + s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ + "url": req.URL.String(), + "token_type": tokenType, + "mimic_claude_code": strconv.FormatBool(mimicClaudeCode), + "fingerprint_applied": strconv.FormatBool(fingerprint != nil), + "enable_fp": strconv.FormatBool(enableFP), + "enable_mpt": strconv.FormatBool(enableMPT), + }) + // Always capture a compact fingerprint line for later error diagnostics. // We only print it when needed (or when the explicit debug flag is enabled). if c != nil && tokenType == "oauth" { @@ -5771,24 +5806,21 @@ func defaultAPIKeyBetaHeader(body []byte) string { return claude.APIKeyBetaHeader } -func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) { +func applyClaudeOAuthHeaderDefaults(req *http.Request) { if req == nil { return } - if req.Header.Get("accept") == "" { - req.Header.Set("accept", "application/json") + if getHeaderRaw(req.Header, "Accept") == "" { + setHeaderRaw(req.Header, "Accept", "application/json") } for key, value := range claude.DefaultHeaders { if value == "" { continue } - if req.Header.Get(key) == "" { - req.Header.Set(key, value) + if getHeaderRaw(req.Header, key) == "" { + setHeaderRaw(req.Header, resolveWireCasing(key), value) } } - if isStream && req.Header.Get("x-stainless-helper-method") == "" { - req.Header.Set("x-stainless-helper-method", "stream") - } } func mergeAnthropicBeta(required []string, incoming string) string { @@ -6083,18 +6115,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { return } // Start with the standard defaults (fill missing). - applyClaudeOAuthHeaderDefaults(req, isStream) + applyClaudeOAuthHeaderDefaults(req) // Then force key headers to match Claude Code fingerprint regardless of what the client sent. + // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") for key, value := range claude.DefaultHeaders { if value == "" { continue } - req.Header.Set(key, value) + setHeaderRaw(req.Header, resolveWireCasing(key), value) } // Real Claude CLI uses Accept: application/json (even for streaming). - req.Header.Set("accept", "application/json") + setHeaderRaw(req.Header, "Accept", "application/json") if isStream { - req.Header.Set("x-stainless-helper-method", "stream") + setHeaderRaw(req.Header, "x-stainless-helper-method", "stream") } } @@ -8197,8 +8230,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( if !allowedHeaders[lowerKey] { continue } + wireKey := resolveWireCasing(key) for _, v := range values { - req.Header.Add(key, v) + addHeaderRaw(req.Header, wireKey, v) } } } @@ -8239,15 +8273,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con clientHeaders = c.Request.Header } - // OAuth 账号:应用统一指纹和重写 userID + // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 + ctEnableFP, ctEnableMPT := true, false + if s.settingService != nil { + ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx) + } + var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) if err == nil { - accountUUID := account.GetExtraString("account_uuid") - if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { - body = newBody + ctFingerprint = fp + if !ctEnableMPT { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" && fp.ClientID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } } } } @@ -8258,40 +8300,38 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con return nil, err } - // 设置认证头 + // 设置认证头(保持原始大小写) if tokenType == "oauth" { - req.Header.Set("authorization", "Bearer "+token) + setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - req.Header.Set("x-api-key", token) + setHeaderRaw(req.Header, "x-api-key", token) } - // 白名单透传 headers + // 白名单透传 headers(恢复真实 wire casing) for key, values := range clientHeaders { lowerKey := strings.ToLower(key) if allowedHeaders[lowerKey] { + wireKey := resolveWireCasing(key) for _, v := range values { - req.Header.Add(key, v) + addHeaderRaw(req.Header, wireKey, v) } } } - // OAuth 账号:应用指纹到请求头 - if account.IsOAuth() && s.identityService != nil { - fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) - if fp != nil { - s.identityService.ApplyFingerprint(req, fp) - } + // OAuth 账号:应用指纹到请求头(受设置开关控制) + if ctEnableFP && ctFingerprint != nil { + s.identityService.ApplyFingerprint(req, ctFingerprint) } - // 确保必要的 headers 存在 - if req.Header.Get("content-type") == "" { - req.Header.Set("content-type", "application/json") + // 确保必要的 headers 存在(保持原始大小写) + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") } - if req.Header.Get("anthropic-version") == "" { - req.Header.Set("anthropic-version", "2023-06-01") + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") } if tokenType == "oauth" { - applyClaudeOAuthHeaderDefaults(req, false) + applyClaudeOAuthHeaderDefaults(req) } // Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules @@ -8302,30 +8342,30 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if mimicClaudeCode { applyClaudeCodeMimicHeaders(req, false) - incomingBeta := req.Header.Get("anthropic-beta") + incomingBeta := getHeaderRaw(req.Header, "anthropic-beta") requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting} - req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet)) + setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet)) } else { - clientBetaHeader := req.Header.Get("anthropic-beta") + clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta") if clientBetaHeader == "" { - req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader) + setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader) } else { beta := s.getBetaHeader(modelID, clientBetaHeader) if !strings.Contains(beta, claude.BetaTokenCounting) { beta = beta + "," + claude.BetaTokenCounting } - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet)) + setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet)) } } } else { // API-key accounts: apply beta policy filter to strip controlled tokens - if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet)) + if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" { + setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet)) } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { // API-key:与 messages 同步的按需 beta 注入(默认关闭) if requestNeedsBetaFeatures(body) { if beta := defaultAPIKeyBetaHeader(body); beta != "" { - req.Header.Set("anthropic-beta", beta) + setHeaderRaw(req.Header, "anthropic-beta", beta) } } } @@ -8496,42 +8536,94 @@ func reconcileCachedTokens(usage map[string]any) bool { return true } -func debugGatewayBodyLoggingEnabled() bool { - raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)) - if raw == "" { - return false - } +const debugGatewayBodyDefaultFilename = "gateway_debug.log" - switch strings.ToLower(raw) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。 -// 默认关闭,仅在设置环境变量时启用: +// initDebugGatewayBodyFile 初始化网关调试日志文件。 // -// SUB2API_DEBUG_GATEWAY_BODY=1 -func debugLogRequestBody(tag string, body []byte) { - if !debugGatewayBodyLoggingEnabled() { +// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log +// - 已有目录路径 → 该目录下 gateway_debug.log +// - 其他 → 视为完整文件路径 +func (s *GatewayService) initDebugGatewayBodyFile(path string) { + if parseDebugEnvBool(path) { + path = debugGatewayBodyDefaultFilename + } + + // 如果 path 指向一个已存在的目录,自动追加默认文件名 + if info, err := os.Stat(path); err == nil && info.IsDir() { + path = filepath.Join(path, debugGatewayBodyDefaultFilename) + } + + // 确保父目录存在 + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) + return + } + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + slog.Error("failed to open gateway debug log file", "path", path, "error", err) return } - - if len(body) == 0 { - logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag) - return - } - - // 提取 metadata 字段完整打印 - metadataResult := gjson.GetBytes(body, "metadata") - if metadataResult.Exists() { - logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw) - } else { - logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag) - } - - // 全量打印 body - logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body)) + s.debugGatewayBodyFile.Store(f) + slog.Info("gateway debug logging enabled", "path", path) +} + +// debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件, +// 用于对比客户端原始请求和上游转发请求。 +// +// 启用方式(环境变量): +// +// SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log +// SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径 +// +// tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD" +func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) { + f := s.debugGatewayBodyFile.Load() + if f == nil { + return + } + + var buf strings.Builder + ts := time.Now().Format("2006-01-02 15:04:05.000") + fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag) + + // 1. context + if len(extra) > 0 { + fmt.Fprint(&buf, "--- context ---\n") + extraKeys := make([]string, 0, len(extra)) + for k := range extra { + extraKeys = append(extraKeys, k) + } + sort.Strings(extraKeys) + for _, k := range extraKeys { + fmt.Fprintf(&buf, " %s: %s\n", k, extra[k]) + } + } + + // 2. headers(按真实 Claude CLI wire 顺序排列,便于与抓包对比;auth 脱敏) + fmt.Fprint(&buf, "--- headers ---\n") + for _, k := range sortHeadersByWireOrder(headers) { + for _, v := range headers[k] { + fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v)) + } + } + + // 3. body(完整输出,格式化 JSON 便于 diff) + fmt.Fprint(&buf, "--- body ---\n") + if len(body) == 0 { + fmt.Fprint(&buf, " (empty)\n") + } else { + var pretty bytes.Buffer + if json.Indent(&pretty, body, " ", " ") == nil { + fmt.Fprintf(&buf, " %s\n", pretty.Bytes()) + } else { + // JSON 格式化失败时原样输出 + fmt.Fprintf(&buf, " %s\n", body) + } + } + + // 写入文件(调试用,并发写入可能交错但不影响可读性) + _, _ = f.WriteString(buf.String()) } diff --git a/backend/internal/service/header_util.go b/backend/internal/service/header_util.go new file mode 100644 index 00000000..6acfee5a --- /dev/null +++ b/backend/internal/service/header_util.go @@ -0,0 +1,157 @@ +package service + +import ( + "net/http" + "strings" +) + +// headerWireCasing 定义每个白名单 header 在真实 Claude CLI 抓包中的准确大小写。 +// Go 的 HTTP server 解析请求时会将所有 header key 转为 Canonical 形式(如 x-app → X-App), +// 此 map 用于在转发时恢复到真实的 wire format。 +// +// 来源:对真实 Claude CLI (claude-cli/2.1.81) 到 api.anthropic.com 的 HTTPS 流量抓包。 +var headerWireCasing = map[string]string{ + // Title case + "accept": "Accept", + "user-agent": "User-Agent", + + // X-Stainless-* 保持 SDK 原始大小写 + "x-stainless-retry-count": "X-Stainless-Retry-Count", + "x-stainless-timeout": "X-Stainless-Timeout", + "x-stainless-lang": "X-Stainless-Lang", + "x-stainless-package-version": "X-Stainless-Package-Version", + "x-stainless-os": "X-Stainless-OS", + "x-stainless-arch": "X-Stainless-Arch", + "x-stainless-runtime": "X-Stainless-Runtime", + "x-stainless-runtime-version": "X-Stainless-Runtime-Version", + "x-stainless-helper-method": "x-stainless-helper-method", + + // Anthropic SDK 自身设置的 header,全小写 + "anthropic-dangerous-direct-browser-access": "anthropic-dangerous-direct-browser-access", + "anthropic-version": "anthropic-version", + "anthropic-beta": "anthropic-beta", + "x-app": "x-app", + "content-type": "content-type", + "accept-language": "accept-language", + "sec-fetch-mode": "sec-fetch-mode", + "accept-encoding": "accept-encoding", + "authorization": "authorization", +} + +// headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。 +// 用于 debug log 按此顺序输出,便于与抓包结果直接对比。 +var headerWireOrder = []string{ + "Accept", + "X-Stainless-Retry-Count", + "X-Stainless-Timeout", + "X-Stainless-Lang", + "X-Stainless-Package-Version", + "X-Stainless-OS", + "X-Stainless-Arch", + "X-Stainless-Runtime", + "X-Stainless-Runtime-Version", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "authorization", + "x-app", + "User-Agent", + "content-type", + "anthropic-beta", + "accept-language", + "sec-fetch-mode", + "accept-encoding", + "x-stainless-helper-method", +} + +// headerWireOrderSet 用于快速判断某个 key 是否在 headerWireOrder 中(按 lowercase 匹配)。 +var headerWireOrderSet map[string]struct{} + +func init() { + headerWireOrderSet = make(map[string]struct{}, len(headerWireOrder)) + for _, k := range headerWireOrder { + headerWireOrderSet[strings.ToLower(k)] = struct{}{} + } +} + +// resolveWireCasing 将 Go canonical key(如 X-Stainless-Os)映射为真实 wire casing(如 X-Stainless-OS)。 +// 如果 map 中没有对应条目,返回原始 key 不变。 +func resolveWireCasing(key string) string { + if wk, ok := headerWireCasing[strings.ToLower(key)]; ok { + return wk + } + return key +} + +// setHeaderRaw sets a header bypassing Go's canonical-case normalization. +// The key is stored exactly as provided, preserving original casing. +// +// It first removes any existing value under the canonical key, the wire casing key, +// and the exact raw key, preventing duplicates from any source. +func setHeaderRaw(h http.Header, key, value string) { + h.Del(key) // remove canonical form (e.g. "Anthropic-Beta") + if wk := resolveWireCasing(key); wk != key { + delete(h, wk) // remove wire casing form if different + } + delete(h, key) // remove exact raw key if it differs from canonical + h[key] = []string{value} +} + +// addHeaderRaw appends a header value bypassing Go's canonical-case normalization. +func addHeaderRaw(h http.Header, key, value string) { + h[key] = append(h[key], value) +} + +// getHeaderRaw reads a header value, trying multiple key forms to handle the mismatch +// between Go canonical keys, wire casing keys, and raw keys: +// 1. exact key as provided +// 2. wire casing form (from headerWireCasing) +// 3. Go canonical form (via http.Header.Get) +func getHeaderRaw(h http.Header, key string) string { + // 1. exact key + if vals := h[key]; len(vals) > 0 { + return vals[0] + } + // 2. wire casing (e.g. looking up "Anthropic-Dangerous-Direct-Browser-Access" finds "anthropic-dangerous-direct-browser-access") + if wk := resolveWireCasing(key); wk != key { + if vals := h[wk]; len(vals) > 0 { + return vals[0] + } + } + // 3. canonical fallback + return h.Get(key) +} + +// sortHeadersByWireOrder 按照真实 Claude CLI 的 header 顺序返回排序后的 key 列表。 +// 在 headerWireOrder 中定义的 key 按其顺序排列,未定义的 key 追加到末尾。 +func sortHeadersByWireOrder(h http.Header) []string { + // 构建 lowercase -> actual map key 的映射 + present := make(map[string]string, len(h)) + for k := range h { + present[strings.ToLower(k)] = k + } + + result := make([]string, 0, len(h)) + seen := make(map[string]struct{}, len(h)) + + // 先按 wire order 输出 + for _, wk := range headerWireOrder { + lk := strings.ToLower(wk) + if actual, ok := present[lk]; ok { + if _, dup := seen[lk]; !dup { + result = append(result, actual) + seen[lk] = struct{}{} + } + } + } + + // 再追加不在 wire order 中的 header + for k := range h { + lk := strings.ToLower(k) + if _, ok := seen[lk]; !ok { + result = append(result, k) + seen[lk] = struct{}{} + } + } + + return result +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 428f5bfd..3d706508 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -174,6 +174,7 @@ func getHeaderOrDefault(headers http.Header, key, defaultValue string) string { } // ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头) +// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os) func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { if fp == nil { return @@ -181,27 +182,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { // 设置user-agent if fp.UserAgent != "" { - req.Header.Set("user-agent", fp.UserAgent) + setHeaderRaw(req.Header, "User-Agent", fp.UserAgent) } - // 设置x-stainless-*头 + // 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写) if fp.StainlessLang != "" { - req.Header.Set("X-Stainless-Lang", fp.StainlessLang) + setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang) } if fp.StainlessPackageVersion != "" { - req.Header.Set("X-Stainless-Package-Version", fp.StainlessPackageVersion) + setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion) } if fp.StainlessOS != "" { - req.Header.Set("X-Stainless-OS", fp.StainlessOS) + setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS) } if fp.StainlessArch != "" { - req.Header.Set("X-Stainless-Arch", fp.StainlessArch) + setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch) } if fp.StainlessRuntime != "" { - req.Header.Set("X-Stainless-Runtime", fp.StainlessRuntime) + setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime) } if fp.StainlessRuntimeVersion != "" { - req.Header.Set("X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion) + setHeaderRaw(req.Header, "X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion) } } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 44d20491..1a24bad1 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -79,6 +79,20 @@ const backendModeCacheTTL = 60 * time.Second const backendModeErrorTTL = 5 * time.Second const backendModeDBTimeout = 5 * time.Second +// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) +type cachedGatewayForwardingSettings struct { + fingerprintUnification bool + metadataPassthrough bool + expiresAt int64 // unix nano +} + +var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings +var gatewayForwardingSF singleflight.Group + +const gatewayForwardingCacheTTL = 60 * time.Second +const gatewayForwardingErrorTTL = 5 * time.Second +const gatewayForwardingDBTimeout = 5 * time.Second + // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) @@ -510,6 +524,10 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Backend Mode updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) + // Gateway forwarding behavior + updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) + updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) + err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 @@ -524,6 +542,12 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet value: settings.BackendModeEnabled, expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), }) + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: settings.EnableFingerprintUnification, + metadataPassthrough: settings.EnableMetadataPassthrough, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } @@ -626,6 +650,57 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { return false } +// GetGatewayForwardingSettings returns cached gateway forwarding settings. +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. +// Returns (fingerprintUnification, metadataPassthrough). +func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough bool) { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.fingerprintUnification, cached.metadataPassthrough + } + } + type gwfResult struct { + fp, mp bool + } + val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough}, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyEnableFingerprintUnification, + SettingKeyEnableMetadataPassthrough, + }) + if err != nil { + slog.Warn("failed to get gateway forwarding settings", "error", err) + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: true, + metadataPassthrough: false, + expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), + }) + return gwfResult{true, false}, nil + } + fp := true + if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { + fp = v == "true" + } + mp := values[SettingKeyEnableMetadataPassthrough] == "true" + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: fp, + metadataPassthrough: mp, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) + return gwfResult{fp, mp}, nil + }) + if r, ok := val.(gwfResult); ok { + return r.fp, r.mp + } + return true, false // fail-open defaults +} + // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) @@ -923,6 +998,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 分组隔离 result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" + // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false) + if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { + result.EnableFingerprintUnification = v == "true" + } else { + result.EnableFingerprintUnification = true // default: enabled (current behavior) + } + result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" + return result } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index cf1d5eed..4e29dba5 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -75,6 +75,10 @@ type SystemSettings struct { // Backend 模式:禁用用户注册和自助服务,仅管理员可登录 BackendModeEnabled bool + + // Gateway forwarding behavior + EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) + EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) } type DefaultSubscriptionSetting struct { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 83258bcc..196e3788 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -86,6 +86,10 @@ export interface SystemSettings { // 分组隔离 allow_ungrouped_key_scheduling: boolean + + // Gateway forwarding behavior + enable_fingerprint_unification: boolean + enable_metadata_passthrough: boolean } export interface UpdateSettingsRequest { @@ -142,6 +146,8 @@ export interface UpdateSettingsRequest { min_claude_code_version?: string max_claude_code_version?: string allow_ungrouped_key_scheduling?: boolean + enable_fingerprint_unification?: boolean + enable_metadata_passthrough?: boolean } /** diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ad916a65..4353b14b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4171,6 +4171,14 @@ export default { allowUngroupedKey: 'Allow Ungrouped Key Scheduling', allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.' }, + gatewayForwarding: { + title: 'Request Forwarding', + description: 'Control how requests are forwarded to upstream OAuth accounts', + fingerprintUnification: 'Fingerprint Unification', + fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', + metadataPassthrough: 'Metadata Passthrough', + metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', + }, site: { title: 'Site Settings', description: 'Customize site branding', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 042fca26..6dac7fee 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4334,6 +4334,14 @@ export default { allowUngroupedKey: '允许未分组 Key 调度', allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。' }, + gatewayForwarding: { + title: '请求转发行为', + description: '控制请求转发到上游 OAuth 账号时的行为', + fingerprintUnification: '指纹统一化', + fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', + metadataPassthrough: 'Metadata 透传', + metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', + }, site: { title: '站点设置', description: '自定义站点品牌', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 00105eb9..0e510aa9 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1171,6 +1171,45 @@ + + +
+ {{ t('admin.settings.gatewayForwarding.description') }} +
++ {{ t('admin.settings.gatewayForwarding.fingerprintUnificationHint') }} +
++ {{ t('admin.settings.gatewayForwarding.metadataPassthroughHint') }} +
+