"git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "a2f3d10beee4a1ab7d84fa12b74c804cbbd7ffc7"
Commit b20e1422 authored by shaw's avatar shaw
Browse files

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 存储方式
parent 0f033930
...@@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
}) })
} }
...@@ -209,6 +211,10 @@ type UpdateSettingsRequest struct { ...@@ -209,6 +211,10 @@ type UpdateSettingsRequest struct {
// Backend Mode // Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
} }
// UpdateSettings 更新系统设置 // UpdateSettings 更新系统设置
...@@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
return previousSettings.OpsMetricsIntervalSeconds 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 { if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
...@@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled, BackendModeEnabled: updatedSettings.BackendModeEnabled,
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
}) })
} }
...@@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.CustomMenuItems != after.CustomMenuItems { if before.CustomMenuItems != after.CustomMenuItems {
changed = append(changed, "custom_menu_items") 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 return changed
} }
......
...@@ -94,6 +94,10 @@ type SystemSettings struct { ...@@ -94,6 +94,10 @@ type SystemSettings struct {
// Backend Mode // Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
} }
type DefaultSubscriptionSetting struct { type DefaultSubscriptionSetting struct {
......
...@@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod ...@@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
SetContext(ctx). SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4"). SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody). SetBody(reqBody).
SetSuccessResult(&tokenResp). SetSuccessResult(&tokenResp).
Post(s.tokenURL) Post(s.tokenURL)
...@@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro ...@@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
SetContext(ctx). SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4"). SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody). SetBody(reqBody).
SetSuccessResult(&tokenResp). SetSuccessResult(&tokenResp).
Post(s.tokenURL) Post(s.tokenURL)
......
package repository package repository
import ( import (
"compress/flate"
"compress/gzip"
"errors" "errors"
"fmt" "fmt"
"io" "io"
...@@ -13,6 +15,8 @@ import ( ...@@ -13,6 +15,8 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/andybalholm/brotli"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
...@@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i ...@@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return nil, err return nil, err
} }
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳 // 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE)在完全读取前不会被淘汰 // 这确保了流式响应(如 SSE)在完全读取前不会被淘汰
resp.Body = wrapTrackedBody(resp.Body, func() { resp.Body = wrapTrackedBody(resp.Body, func() {
...@@ -218,6 +225,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco ...@@ -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) slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳 // 包装响应体,在关闭时自动减少计数并更新时间戳
resp.Body = wrapTrackedBody(resp.Body, func() { resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1) atomic.AddInt64(&entry.inFlight, -1)
...@@ -884,3 +894,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser { ...@@ -884,3 +894,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
} }
return &trackedBody{ReadCloser: body, onClose: onClose} 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()
}
...@@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) { ...@@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) {
"max_claude_code_version": "", "max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false, "allow_ungrouped_key_scheduling": false,
"backend_mode_enabled": false, "backend_mode_enabled": false,
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
"custom_menu_items": [], "custom_menu_items": [],
"custom_endpoints": [] "custom_endpoints": []
} }
......
...@@ -235,6 +235,12 @@ const ( ...@@ -235,6 +235,12 @@ const (
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录 // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
SettingKeyBackendModeEnabled = "backend_mode_enabled" 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). // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
......
...@@ -175,13 +175,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd ...@@ -175,13 +175,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射") 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.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("authorization")) require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key")) require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("cookie")) require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version")) require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version"))
require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta")) require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头") require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
require.Contains(t, rec.Body.String(), `"cached_tokens":7`) require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写") require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
...@@ -257,9 +257,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo ...@@ -257,9 +257,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射") 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.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
require.Empty(t, upstream.lastReq.Header.Get("authorization")) require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Empty(t, upstream.lastReq.Header.Get("cookie")) require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, upstreamRespBody, rec.Body.String()) require.JSONEq(t, upstreamRespBody, rec.Body.String())
require.Empty(t, rec.Header().Get("Set-Cookie")) require.Empty(t, rec.Header().Get("Set-Cookie"))
...@@ -684,8 +684,8 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t ...@@ -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) 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.NoError(t, err)
require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization")) require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization"))
require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
} }
func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
...@@ -755,8 +755,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( ...@@ -755,8 +755,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.NotNil(t, result)
require.NotNil(t, upstream.lastReq) require.NotNil(t, upstream.lastReq)
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization")) require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization"))
require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth) require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth)
system := gjson.GetBytes(upstream.lastBody, "system") system := gjson.GetBytes(upstream.lastBody, "system")
require.True(t, system.Exists()) require.True(t, system.Exists())
......
...@@ -2,31 +2,28 @@ package service ...@@ -2,31 +2,28 @@ package service
import "testing" import "testing"
func TestDebugGatewayBodyLoggingEnabled(t *testing.T) { func TestParseDebugEnvBool(t *testing.T) {
t.Run("default disabled", func(t *testing.T) { t.Run("empty is false", func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, "") if parseDebugEnvBool("") {
if debugGatewayBodyLoggingEnabled() { t.Fatalf("expected false for empty string")
t.Fatalf("expected debug gateway body logging to be disabled by default")
} }
}) })
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"} { for _, value := range []string{"1", "true", "TRUE", "yes", "on"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, value) if !parseDebugEnvBool(value) {
if !debugGatewayBodyLoggingEnabled() { t.Fatalf("expected true for %q", value)
t.Fatalf("expected debug gateway body logging to be enabled 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"} { for _, value := range []string{"0", "false", "off", "debug"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Setenv(debugGatewayBodyEnv, value) if parseDebugEnvBool(value) {
if debugGatewayBodyLoggingEnabled() { t.Fatalf("expected false for %q", value)
t.Fatalf("expected debug gateway body logging to be disabled for %q", value)
} }
}) })
} }
......
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
mathrand "math/rand" mathrand "math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
...@@ -366,6 +367,7 @@ var allowedHeaders = map[string]bool{ ...@@ -366,6 +367,7 @@ var allowedHeaders = map[string]bool{
"sec-fetch-mode": true, "sec-fetch-mode": true,
"user-agent": true, "user-agent": true,
"content-type": true, "content-type": true,
"accept-encoding": true,
} }
// GatewayCache 定义网关服务的缓存操作接口。 // GatewayCache 定义网关服务的缓存操作接口。
...@@ -563,6 +565,7 @@ type GatewayService struct { ...@@ -563,6 +565,7 @@ type GatewayService struct {
responseHeaderFilter *responseheaders.CompiledHeaderFilter responseHeaderFilter *responseheaders.CompiledHeaderFilter
debugModelRouting atomic.Bool debugModelRouting atomic.Bool
debugClaudeMimic atomic.Bool debugClaudeMimic atomic.Bool
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
} }
// NewGatewayService creates a new GatewayService // NewGatewayService creates a new GatewayService
...@@ -630,6 +633,9 @@ func NewGatewayService( ...@@ -630,6 +633,9 @@ func NewGatewayService(
) )
svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING"))) svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC"))) svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
svc.initDebugGatewayBodyFile(path)
}
return svc return svc
} }
...@@ -4048,8 +4054,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -4048,8 +4054,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
reqStream := parsed.Stream reqStream := parsed.Stream
originalModel := reqModel originalModel := reqModel
// === DEBUG: 打印客户端原始请求 body === // === DEBUG: 打印客户端原始请求(headers + body 摘要)===
debugLogRequestBody("CLIENT_ORIGINAL", 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) isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
...@@ -4066,9 +4079,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -4066,9 +4079,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if s.identityService != nil { if s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil { if err == nil && fp != nil {
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { // metadata 透传开启时跳过 metadata 注入
normalizeOpts.injectMetadata = true _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
normalizeOpts.metadataUserID = metadataUserID if !mimicMPT {
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
normalizeOpts.injectMetadata = true
normalizeOpts.metadataUserID = metadataUserID
}
} }
} }
} }
...@@ -4840,8 +4857,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( ...@@ -4840,8 +4857,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] { if !allowedHeaders[lowerKey] {
continue continue
} }
wireKey := resolveWireCasing(key)
for _, v := range values { for _, v := range values {
req.Header.Add(key, v) addHeaderRaw(req.Header, wireKey, v)
} }
} }
} }
...@@ -4851,13 +4869,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( ...@@ -4851,13 +4869,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
req.Header.Del("x-api-key") req.Header.Del("x-api-key")
req.Header.Del("x-goog-api-key") req.Header.Del("x-goog-api-key")
req.Header.Del("cookie") req.Header.Del("cookie")
req.Header.Set("x-api-key", token) setHeaderRaw(req.Header, "x-api-key", token)
if req.Header.Get("content-type") == "" { if getHeaderRaw(req.Header, "content-type") == "" {
req.Header.Set("content-type", "application/json") setHeaderRaw(req.Header, "content-type", "application/json")
} }
if req.Header.Get("anthropic-version") == "" { if getHeaderRaw(req.Header, "anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01") setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
} }
return req, nil return req, nil
...@@ -5591,8 +5609,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex ...@@ -5591,8 +5609,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
clientHeaders = c.Request.Header clientHeaders = c.Request.Header
} }
// OAuth账号:应用统一指纹 // OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
var fingerprint *Fingerprint var fingerprint *Fingerprint
enableFP, enableMPT := true, false
if s.settingService != nil {
enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
if account.IsOAuth() && s.identityService != nil { if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹(包含随机生成的ClientID) // 1. 获取或创建指纹(包含随机生成的ClientID)
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
...@@ -5600,40 +5622,43 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex ...@@ -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) logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
// 失败时降级为透传原始headers // 失败时降级为透传原始headers
} else { } else {
fingerprint = fp if enableFP {
fingerprint = fp
}
// 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid) // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
accountUUID := account.GetExtraString("account_uuid") // 当 metadata 透传开启时跳过重写
if accountUUID != "" && fp.ClientID != "" { if !enableMPT {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { accountUUID := account.GetExtraString("account_uuid")
body = newBody 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)) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 设置认证头 // 设置认证头(保持原始大小写)
if tokenType == "oauth" { if tokenType == "oauth" {
req.Header.Set("authorization", "Bearer "+token) setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else { } else {
req.Header.Set("x-api-key", token) setHeaderRaw(req.Header, "x-api-key", token)
} }
// 白名单透传headers // 白名单透传headers(恢复真实 wire casing)
for key, values := range clientHeaders { for key, values := range clientHeaders {
lowerKey := strings.ToLower(key) lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] { if allowedHeaders[lowerKey] {
wireKey := resolveWireCasing(key)
for _, v := range values { 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 ...@@ -5643,15 +5668,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
s.identityService.ApplyFingerprint(req, fingerprint) s.identityService.ApplyFingerprint(req, fingerprint)
} }
// 确保必要的headers存在 // 确保必要的headers存在(保持原始大小写)
if req.Header.Get("content-type") == "" { if getHeaderRaw(req.Header, "content-type") == "" {
req.Header.Set("content-type", "application/json") setHeaderRaw(req.Header, "content-type", "application/json")
} }
if req.Header.Get("anthropic-version") == "" { if getHeaderRaw(req.Header, "anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01") setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
} }
if tokenType == "oauth" { if tokenType == "oauth" {
applyClaudeOAuthHeaderDefaults(req, reqStream) applyClaudeOAuthHeaderDefaults(req)
} }
// Build effective drop set: merge static defaults with dynamic beta policy filter rules // 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 ...@@ -5667,31 +5692,41 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在 // - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
applyClaudeCodeMimicHeaders(req, reqStream) applyClaudeCodeMimicHeaders(req, reqStream)
incomingBeta := req.Header.Get("anthropic-beta") incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
// Match real Claude CLI traffic (per mitmproxy reports): // Match real Claude CLI traffic (per mitmproxy reports):
// messages requests typically use only oauth + interleaved-thinking. // messages requests typically use only oauth + interleaved-thinking.
// Also drop claude-code beta if a downstream client added it. // Also drop claude-code beta if a downstream client added it.
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} 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 { } else {
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
clientBetaHeader := req.Header.Get("anthropic-beta") clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet)) setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
} }
} else { } else {
// API-key accounts: apply beta policy filter to strip controlled tokens // API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet)) setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
if requestNeedsBetaFeatures(body) { if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" { 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. // Always capture a compact fingerprint line for later error diagnostics.
// We only print it when needed (or when the explicit debug flag is enabled). // We only print it when needed (or when the explicit debug flag is enabled).
if c != nil && tokenType == "oauth" { if c != nil && tokenType == "oauth" {
...@@ -5771,24 +5806,21 @@ func defaultAPIKeyBetaHeader(body []byte) string { ...@@ -5771,24 +5806,21 @@ func defaultAPIKeyBetaHeader(body []byte) string {
return claude.APIKeyBetaHeader return claude.APIKeyBetaHeader
} }
func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) { func applyClaudeOAuthHeaderDefaults(req *http.Request) {
if req == nil { if req == nil {
return return
} }
if req.Header.Get("accept") == "" { if getHeaderRaw(req.Header, "Accept") == "" {
req.Header.Set("accept", "application/json") setHeaderRaw(req.Header, "Accept", "application/json")
} }
for key, value := range claude.DefaultHeaders { for key, value := range claude.DefaultHeaders {
if value == "" { if value == "" {
continue continue
} }
if req.Header.Get(key) == "" { if getHeaderRaw(req.Header, key) == "" {
req.Header.Set(key, value) 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 { func mergeAnthropicBeta(required []string, incoming string) string {
...@@ -6083,18 +6115,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { ...@@ -6083,18 +6115,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
return return
} }
// Start with the standard defaults (fill missing). // 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. // 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 { for key, value := range claude.DefaultHeaders {
if value == "" { if value == "" {
continue continue
} }
req.Header.Set(key, value) setHeaderRaw(req.Header, resolveWireCasing(key), value)
} }
// Real Claude CLI uses Accept: application/json (even for streaming). // Real Claude CLI uses Accept: application/json (even for streaming).
req.Header.Set("accept", "application/json") setHeaderRaw(req.Header, "Accept", "application/json")
if isStream { 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( ...@@ -8197,8 +8230,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] { if !allowedHeaders[lowerKey] {
continue continue
} }
wireKey := resolveWireCasing(key)
for _, v := range values { 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 ...@@ -8239,15 +8273,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
clientHeaders = c.Request.Header clientHeaders = c.Request.Header
} }
// OAuth 账号:应用统一指纹和重写 userID // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 // 如果启用了会话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 { if account.IsOAuth() && s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
if err == nil { if err == nil {
accountUUID := account.GetExtraString("account_uuid") ctFingerprint = fp
if accountUUID != "" && fp.ClientID != "" { if !ctEnableMPT {
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { accountUUID := account.GetExtraString("account_uuid")
body = newBody 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 ...@@ -8258,40 +8300,38 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
return nil, err return nil, err
} }
// 设置认证头 // 设置认证头(保持原始大小写)
if tokenType == "oauth" { if tokenType == "oauth" {
req.Header.Set("authorization", "Bearer "+token) setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else { } else {
req.Header.Set("x-api-key", token) setHeaderRaw(req.Header, "x-api-key", token)
} }
// 白名单透传 headers // 白名单透传 headers(恢复真实 wire casing)
for key, values := range clientHeaders { for key, values := range clientHeaders {
lowerKey := strings.ToLower(key) lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] { if allowedHeaders[lowerKey] {
wireKey := resolveWireCasing(key)
for _, v := range values { for _, v := range values {
req.Header.Add(key, v) addHeaderRaw(req.Header, wireKey, v)
} }
} }
} }
// OAuth 账号:应用指纹到请求头 // OAuth 账号:应用指纹到请求头(受设置开关控制)
if account.IsOAuth() && s.identityService != nil { if ctEnableFP && ctFingerprint != nil {
fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) s.identityService.ApplyFingerprint(req, ctFingerprint)
if fp != nil {
s.identityService.ApplyFingerprint(req, fp)
}
} }
// 确保必要的 headers 存在 // 确保必要的 headers 存在(保持原始大小写)
if req.Header.Get("content-type") == "" { if getHeaderRaw(req.Header, "content-type") == "" {
req.Header.Set("content-type", "application/json") setHeaderRaw(req.Header, "content-type", "application/json")
} }
if req.Header.Get("anthropic-version") == "" { if getHeaderRaw(req.Header, "anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01") setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
} }
if tokenType == "oauth" { if tokenType == "oauth" {
applyClaudeOAuthHeaderDefaults(req, false) applyClaudeOAuthHeaderDefaults(req)
} }
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules // 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 ...@@ -8302,30 +8342,30 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if mimicClaudeCode { if mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false) 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} 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 { } else {
clientBetaHeader := req.Header.Get("anthropic-beta") clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
if clientBetaHeader == "" { if clientBetaHeader == "" {
req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader) setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
} else { } else {
beta := s.getBetaHeader(modelID, clientBetaHeader) beta := s.getBetaHeader(modelID, clientBetaHeader)
if !strings.Contains(beta, claude.BetaTokenCounting) { if !strings.Contains(beta, claude.BetaTokenCounting) {
beta = beta + "," + claude.BetaTokenCounting beta = beta + "," + claude.BetaTokenCounting
} }
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet)) setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
} }
} }
} else { } else {
// API-key accounts: apply beta policy filter to strip controlled tokens // API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet)) setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key:与 messages 同步的按需 beta 注入(默认关闭) // API-key:与 messages 同步的按需 beta 注入(默认关闭)
if requestNeedsBetaFeatures(body) { if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" { 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 { ...@@ -8496,42 +8536,94 @@ func reconcileCachedTokens(usage map[string]any) bool {
return true return true
} }
func debugGatewayBodyLoggingEnabled() bool { const debugGatewayBodyDefaultFilename = "gateway_debug.log"
raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv))
if raw == "" { // initDebugGatewayBodyFile 初始化网关调试日志文件。
return false //
// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
// - 已有目录路径 → 该目录下 gateway_debug.log
// - 其他 → 视为完整文件路径
func (s *GatewayService) initDebugGatewayBodyFile(path string) {
if parseDebugEnvBool(path) {
path = debugGatewayBodyDefaultFilename
} }
switch strings.ToLower(raw) { // 如果 path 指向一个已存在的目录,自动追加默认文件名
case "1", "true", "yes", "on": if info, err := os.Stat(path); err == nil && info.IsDir() {
return true path = filepath.Join(path, debugGatewayBodyDefaultFilename)
default: }
return false
// 确保父目录存在
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
} }
s.debugGatewayBodyFile.Store(f)
slog.Info("gateway debug logging enabled", "path", path)
} }
// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。 // debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件,
// 默认关闭,仅在设置环境变量时启用: // 用于对比客户端原始请求和上游转发请求。
//
// 启用方式(环境变量):
// //
// SUB2API_DEBUG_GATEWAY_BODY=1 // SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
func debugLogRequestBody(tag string, body []byte) { // SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
if !debugGatewayBodyLoggingEnabled() { //
// 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 return
} }
if len(body) == 0 { var buf strings.Builder
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag) ts := time.Now().Format("2006-01-02 15:04:05.000")
return 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))
}
} }
// 提取 metadata 字段完整打印 // 3. body(完整输出,格式化 JSON 便于 diff)
metadataResult := gjson.GetBytes(body, "metadata") fmt.Fprint(&buf, "--- body ---\n")
if metadataResult.Exists() { if len(body) == 0 {
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw) fmt.Fprint(&buf, " (empty)\n")
} else { } else {
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag) 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)
}
} }
// 全量打印 body // 写入文件(调试用,并发写入可能交错但不影响可读性)
logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body)) _, _ = f.WriteString(buf.String())
} }
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 { ...@@ -174,6 +174,7 @@ func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
} }
// ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头) // ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头)
// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os)
func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
if fp == nil { if fp == nil {
return return
...@@ -181,27 +182,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { ...@@ -181,27 +182,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
// 设置user-agent // 设置user-agent
if fp.UserAgent != "" { 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 != "" { if fp.StainlessLang != "" {
req.Header.Set("X-Stainless-Lang", fp.StainlessLang) setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
} }
if fp.StainlessPackageVersion != "" { if fp.StainlessPackageVersion != "" {
req.Header.Set("X-Stainless-Package-Version", fp.StainlessPackageVersion) setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion)
} }
if fp.StainlessOS != "" { if fp.StainlessOS != "" {
req.Header.Set("X-Stainless-OS", fp.StainlessOS) setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS)
} }
if fp.StainlessArch != "" { if fp.StainlessArch != "" {
req.Header.Set("X-Stainless-Arch", fp.StainlessArch) setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch)
} }
if fp.StainlessRuntime != "" { if fp.StainlessRuntime != "" {
req.Header.Set("X-Stainless-Runtime", fp.StainlessRuntime) setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime)
} }
if fp.StainlessRuntimeVersion != "" { 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 ...@@ -79,6 +79,20 @@ const backendModeCacheTTL = 60 * time.Second
const backendModeErrorTTL = 5 * time.Second const backendModeErrorTTL = 5 * time.Second
const backendModeDBTimeout = 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. // DefaultSubscriptionGroupReader validates group references used by default subscriptions.
type DefaultSubscriptionGroupReader interface { type DefaultSubscriptionGroupReader interface {
GetByID(ctx context.Context, id int64) (*Group, error) GetByID(ctx context.Context, id int64) (*Group, error)
...@@ -510,6 +524,10 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -510,6 +524,10 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
// Backend Mode // Backend Mode
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) 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) err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil { if err == nil {
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
...@@ -524,6 +542,12 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -524,6 +542,12 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
value: settings.BackendModeEnabled, value: settings.BackendModeEnabled,
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), 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 { if s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update s.onUpdate() // Invalidate cache after settings update
} }
...@@ -626,6 +650,57 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { ...@@ -626,6 +650,57 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
return false 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 检查是否开启邮件验证 // IsEmailVerifyEnabled 检查是否开启邮件验证
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
...@@ -923,6 +998,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -923,6 +998,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// 分组隔离 // 分组隔离
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" 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 return result
} }
......
...@@ -75,6 +75,10 @@ type SystemSettings struct { ...@@ -75,6 +75,10 @@ type SystemSettings struct {
// Backend 模式:禁用用户注册和自助服务,仅管理员可登录 // Backend 模式:禁用用户注册和自助服务,仅管理员可登录
BackendModeEnabled bool BackendModeEnabled bool
// Gateway forwarding behavior
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
} }
type DefaultSubscriptionSetting struct { type DefaultSubscriptionSetting struct {
......
...@@ -86,6 +86,10 @@ export interface SystemSettings { ...@@ -86,6 +86,10 @@ export interface SystemSettings {
// 分组隔离 // 分组隔离
allow_ungrouped_key_scheduling: boolean allow_ungrouped_key_scheduling: boolean
// Gateway forwarding behavior
enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
...@@ -142,6 +146,8 @@ export interface UpdateSettingsRequest { ...@@ -142,6 +146,8 @@ export interface UpdateSettingsRequest {
min_claude_code_version?: string min_claude_code_version?: string
max_claude_code_version?: string max_claude_code_version?: string
allow_ungrouped_key_scheduling?: boolean allow_ungrouped_key_scheduling?: boolean
enable_fingerprint_unification?: boolean
enable_metadata_passthrough?: boolean
} }
/** /**
......
...@@ -4171,6 +4171,14 @@ export default { ...@@ -4171,6 +4171,14 @@ export default {
allowUngroupedKey: 'Allow Ungrouped Key Scheduling', 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.' 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: { site: {
title: 'Site Settings', title: 'Site Settings',
description: 'Customize site branding', description: 'Customize site branding',
......
...@@ -4334,6 +4334,14 @@ export default { ...@@ -4334,6 +4334,14 @@ export default {
allowUngroupedKey: '允许未分组 Key 调度', allowUngroupedKey: '允许未分组 Key 调度',
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。' allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
}, },
gatewayForwarding: {
title: '请求转发行为',
description: '控制请求转发到上游 OAuth 账号时的行为',
fingerprintUnification: '指纹统一化',
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
metadataPassthrough: 'Metadata 透传',
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
},
site: { site: {
title: '站点设置', title: '站点设置',
description: '自定义站点品牌', description: '自定义站点品牌',
......
...@@ -1171,6 +1171,45 @@ ...@@ -1171,6 +1171,45 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Gateway Forwarding Behavior -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.gatewayForwarding.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.gatewayForwarding.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Fingerprint Unification -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.gatewayForwarding.fingerprintUnification') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.gatewayForwarding.fingerprintUnificationHint') }}
</p>
</div>
<Toggle v-model="form.enable_fingerprint_unification" />
</div>
<!-- Metadata Passthrough -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.gatewayForwarding.metadataPassthrough') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.gatewayForwarding.metadataPassthroughHint') }}
</p>
</div>
<Toggle v-model="form.enable_metadata_passthrough" />
</div>
</div>
</div>
</div><!-- /Tab: Gateway Claude Code, Scheduling --> </div><!-- /Tab: Gateway Claude Code, Scheduling -->
<!-- Tab: General --> <!-- Tab: General -->
...@@ -2066,7 +2105,10 @@ const form = reactive<SettingsForm>({ ...@@ -2066,7 +2105,10 @@ const form = reactive<SettingsForm>({
min_claude_code_version: '', min_claude_code_version: '',
max_claude_code_version: '', max_claude_code_version: '',
// 分组隔离 // 分组隔离
allow_ungrouped_key_scheduling: false allow_ungrouped_key_scheduling: false,
// Gateway forwarding behavior
enable_fingerprint_unification: true,
enable_metadata_passthrough: false
}) })
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() => const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
...@@ -2373,7 +2415,9 @@ async function saveSettings() { ...@@ -2373,7 +2415,9 @@ async function saveSettings() {
identity_patch_prompt: form.identity_patch_prompt, identity_patch_prompt: form.identity_patch_prompt,
min_claude_code_version: form.min_claude_code_version, min_claude_code_version: form.min_claude_code_version,
max_claude_code_version: form.max_claude_code_version, max_claude_code_version: form.max_claude_code_version,
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
enable_fingerprint_unification: form.enable_fingerprint_unification,
enable_metadata_passthrough: form.enable_metadata_passthrough
} }
const updated = await adminAPI.settings.updateSettings(payload) const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated) Object.assign(form, updated)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment