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)
} }
}) })
} }
......
This diff is collapsed.
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