feat: 网关请求头 wire casing 保持、转发行为开关、调试日志增强及 accept-encoding 恢复
- 新增 header_util.go,通过 setHeaderRaw/getHeaderRaw/addHeaderRaw 绕过 Go 的 canonical-case 规范化,保持真实 Claude CLI 抓包的请求头大小写 (如 "x-app" 而非 "X-App","X-Stainless-OS" 而非 "X-Stainless-Os") - 新增管理后台开关:指纹统一化(默认开启)和 metadata 透传(默认关闭), 使用 atomic.Value + singleflight 缓存模式,60s TTL - 调试日志从控制台 body 打印升级为文件级完整快照 (按真实 wire 顺序输出 headers + 格式化 JSON body + 上下文元数据) - 恢复 accept-encoding 到白名单,在 http_upstream.go 新增 decompressResponseBody 处理 gzip/brotli/deflate 解压(Go 显式设置 Accept-Encoding 时不会自动解压) - OAuth 服务 axios UA 从 1.8.4 更新至 1.13.6 - 测试断言改用 getHeaderRaw 适配 raw header 存储方式
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
157
backend/internal/service/header_util.go
Normal file
157
backend/internal/service/header_util.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ type SystemSettings struct {
|
||||
|
||||
// Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
BackendModeEnabled bool
|
||||
|
||||
// Gateway forwarding behavior
|
||||
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
|
||||
Reference in New Issue
Block a user